From 6b8ae0d7a46d0c5dba84432780604fa3b2ee392b Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Sun, 27 Jul 2025 15:09:15 +0200 Subject: [PATCH 01/23] .NET 8+ only, IIdentity implements IWrapperValueObject, bug fixes, trimming, warnings. --- .../DomainModeling.Example.csproj | 2 +- .../DummyBuilderGenerator.cs | 26 +++++++++---------- DomainModeling.Generator/IdentityGenerator.cs | 12 ++++----- .../TypeSymbolExtensions.cs | 15 +++++++++++ .../ValueObjectGenerator.cs | 16 +++++++----- .../WrapperValueObjectGenerator.cs | 12 ++++----- .../DomainModeling.Tests.csproj | 2 +- .../Configuration/IDomainEventConfigurator.cs | 2 ++ .../Configuration/IEntityConfigurator.cs | 2 ++ .../Configuration/IIdentityConfigurator.cs | 2 ++ .../IWrapperValueObjectConfigurator.cs | 2 ++ .../Conversions/DomainObjectSerializer.cs | 26 ++++++++++++++----- DomainModeling/DomainModeling.csproj | 15 ++++++++--- DomainModeling/IIdentity.cs | 14 +++++++++- DomainModeling/IValueObject.cs | 5 +--- DomainModeling/IWrapperValueObject.cs | 15 ++++++++--- 16 files changed, 117 insertions(+), 51 deletions(-) diff --git a/DomainModeling.Example/DomainModeling.Example.csproj b/DomainModeling.Example/DomainModeling.Example.csproj index 3b6d12e..b8f0085 100644 --- a/DomainModeling.Example/DomainModeling.Example.csproj +++ b/DomainModeling.Example/DomainModeling.Example.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 Architect.DomainModeling.Example Architect.DomainModeling.Example Enable diff --git a/DomainModeling.Generator/DummyBuilderGenerator.cs b/DomainModeling.Generator/DummyBuilderGenerator.cs index 55768a0..6e5b2e7 100644 --- a/DomainModeling.Generator/DummyBuilderGenerator.cs +++ b/DomainModeling.Generator/DummyBuilderGenerator.cs @@ -49,8 +49,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella var result = new Builder() { - TypeFullyQualifiedName = type.ToString(), - ModelTypeFullyQualifiedName = modelType.ToString(), + TypeFullMetadataName = type.GetFullMetadataName(), + ModelTypeFullMetadataName = modelType is INamedTypeSymbol namedModelType ? namedModelType.GetFullMetadataName() : modelType.ToString(), IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword), IsRecord = type.IsRecord, IsClass = type.TypeKind == TypeKind.Class, @@ -91,15 +91,15 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr var concreteBuilderTypesByModel = builders .Where(builder => !builder.IsAbstract && !builder.IsGeneric) // Concrete only - .GroupBy(builder => builder.ModelTypeFullyQualifiedName) // Deduplicate - .Select(group => new KeyValuePair(compilation.GetTypeByMetadataName(group.Key), group.First().TypeFullyQualifiedName)) - .Where(pair => pair.Key is not null) + .GroupBy(builder => builder.ModelTypeFullMetadataName) // Deduplicate + .Select(group => new KeyValuePair(compilation.GetTypeByMetadataName(group.Key), compilation.GetTypeByMetadataName(group.First().TypeFullMetadataName)?.ToString()!)) + .Where(pair => pair.Key is not null && pair.Value is not null) .ToDictionary, ITypeSymbol, string>(pair => pair.Key!, pair => pair.Value, SymbolEqualityComparer.Default); // Remove models for which multiple builders exist { var buildersWithDuplicateModel = builders - .GroupBy(builder => builder.ModelTypeFullyQualifiedName) + .GroupBy(builder => builder.ModelTypeFullMetadataName) .Where(group => group.Count() > 1) .ToList(); @@ -110,7 +110,7 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr builders.Remove(type); context.ReportDiagnostic("DummyBuilderGeneratorDuplicateBuilders", "Duplicate builders", - $"Multiple dummy builders exist for {group.Key}. Source generation for these builders was skipped.", DiagnosticSeverity.Warning, compilation.GetTypeByMetadataName(group.Last().TypeFullyQualifiedName)); + $"Multiple dummy builders exist for {group.Key}. Source generation for these builders was skipped.", DiagnosticSeverity.Warning, compilation.GetTypeByMetadataName(group.Last().TypeFullMetadataName)); } } @@ -118,7 +118,7 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr { context.CancellationToken.ThrowIfCancellationRequested(); - var type = compilation.GetTypeByMetadataName(builder.TypeFullyQualifiedName); + var type = compilation.GetTypeByMetadataName(builder.TypeFullMetadataName); var modelType = type?.GetAttribute("DummyBuilderAttribute", Constants.DomainModelingNamespace, arity: 1) is AttributeData { AttributeClass: not null } attribute ? attribute.AttributeClass.TypeArguments[0] : null; @@ -135,7 +135,7 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr if (type is null) { context.ReportDiagnostic("DummyBuilderGeneratorUnexpectedType", "Unexpected type", - $"Type marked as dummy builder has unexpected type '{builder.TypeFullyQualifiedName}'.", DiagnosticSeverity.Warning, type); + $"Type marked as dummy builder has unexpected type '{builder.TypeFullMetadataName}'.", DiagnosticSeverity.Warning, type); continue; } @@ -143,7 +143,7 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr if (modelType is null) { context.ReportDiagnostic("DummyBuilderGeneratorUnexpectedModelType", "Unexpected model type", - $"Type marked as dummy builder has unexpected model type '{builder.ModelTypeFullyQualifiedName}'.", DiagnosticSeverity.Warning, type); + $"Type marked as dummy builder has unexpected model type '{builder.ModelTypeFullMetadataName}'.", DiagnosticSeverity.Warning, type); continue; } @@ -218,7 +218,7 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr 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, builder.TypeFullyQualifiedName); + concreteBuilderTypesByModel.Add(modelType, builder.TypeFullMetadataName); } if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(param.Type, SymbolEqualityComparer.Default))) @@ -336,8 +336,8 @@ namespace {containingNamespace} private sealed record Builder : IGeneratable { - public string TypeFullyQualifiedName { get; set; } = null!; - public string ModelTypeFullyQualifiedName { get; set; } = null!; + public string TypeFullMetadataName { get; set; } = null!; + public string ModelTypeFullMetadataName { get; set; } = null!; public bool IsPartial { get; set; } public bool IsRecord { get; set; } public bool IsClass { get; set; } diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index 5992e1e..fb1f883 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -124,12 +124,12 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella !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.Arity == 0 && method.Parameters.Length == 0)); + existingComponents |= IdTypeComponents.ToStringOverride.If(members.Any(member => + member.Name == nameof(ToString) && member is IMethodSymbol { IsImplicitlyDeclared: false } 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.Arity == 0 && method.Parameters.Length == 0)); + existingComponents |= IdTypeComponents.GetHashCodeOverride.If(members.Any(member => + member.Name == nameof(GetHashCode) && member is IMethodSymbol { IsImplicitlyDeclared: false } 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 => @@ -137,8 +137,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella 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.Arity == 0 && method.Parameters.Length == 1 && + existingComponents |= IdTypeComponents.EqualsMethod.If(members.Any(member => + member.Name == nameof(Equals) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Arity == 0 && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.CompareToMethod.If(members.Any(member => diff --git a/DomainModeling.Generator/TypeSymbolExtensions.cs b/DomainModeling.Generator/TypeSymbolExtensions.cs index ab255e8..ff39df1 100644 --- a/DomainModeling.Generator/TypeSymbolExtensions.cs +++ b/DomainModeling.Generator/TypeSymbolExtensions.cs @@ -12,6 +12,21 @@ internal static class TypeSymbolExtensions private static IReadOnlyCollection ConversionOperatorNames { get; } = ["op_Implicit", "op_Explicit",]; + /// + /// Returns the full CLR metadata name of the , e.g. "Namespace.Type+NestedGenericType`1". + /// + public static string GetFullMetadataName(this INamedTypeSymbol namedTypeSymbol) + { + // Recurse until we have a non-nested type + if (namedTypeSymbol.IsNested()) + return $"{GetFullMetadataName(namedTypeSymbol.ContainingType)}+{namedTypeSymbol.MetadataName}"; + + // Beware that types may exist in the global namespace + return namedTypeSymbol.ContainingNamespace is INamespaceSymbol ns && !ns.IsGlobalNamespace + ? $"{ns.ToDisplayString()}.{namedTypeSymbol.MetadataName}" + : namedTypeSymbol.MetadataName; + } + /// /// Returns whether the is of type . /// diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index d118a4e..a8ad6bb 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -52,6 +52,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella result.IsGeneric = type.IsGenericType; result.IsNested = type.IsNested(); + result.FullMetadataName = type.GetFullMetadataName(); result.TypeName = type.Name; // Will be non-generic if we pass the conditions to proceed with generation result.ContainingNamespace = type.ContainingNamespace.ToString(); @@ -63,12 +64,12 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella !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.ToStringOverride.If(members.Any(member => + member.Name == nameof(ToString) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Parameters.Length == 0)); // 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)); + existingComponents |= ValueObjectTypeComponents.GetHashCodeOverride.If(members.Any(member => + member.Name == nameof(GetHashCode) && member is IMethodSymbol { IsImplicitlyDeclared: false } 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 => @@ -76,8 +77,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella method.Parameters[0].Type.IsType())); // 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 && + existingComponents |= ValueObjectTypeComponents.EqualsMethod.If(members.Any(member => + member.Name == nameof(Equals) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= ValueObjectTypeComponents.CompareToMethod.If(members.Any(member => @@ -152,7 +153,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var generatable = input.Generatable; var compilation = input.Compilation; - var type = compilation.GetTypeByMetadataName($"{generatable.ContainingNamespace}.{generatable.TypeName}"); + var type = compilation.GetTypeByMetadataName(generatable.FullMetadataName); // Require being able to find the type and attribute if (type is null) @@ -429,6 +430,7 @@ private sealed record Generatable : IGeneratable public bool IsGeneric { get; set; } public bool IsNested { get; set; } public bool IsComparable { get; set; } + public string FullMetadataName { get; set; } = null!; public string TypeName { get; set; } = null!; public string ContainingNamespace { get; set; } = null!; public ValueObjectTypeComponents ExistingComponents { get; set; } diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index 834310f..328e3be 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -97,12 +97,12 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella !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.ToStringOverride.If(members.Any(member => + member.Name == nameof(ToString) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Parameters.Length == 0)); // 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)); + existingComponents |= WrapperValueObjectTypeComponents.GetHashCodeOverride.If(members.Any(member => + member.Name == nameof(GetHashCode) && member is IMethodSymbol { IsImplicitlyDeclared: false } 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 => @@ -110,8 +110,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella method.Parameters[0].Type.IsType())); // 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 && + existingComponents |= WrapperValueObjectTypeComponents.EqualsMethod.If(members.Any(member => + member.Name == nameof(Equals) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Parameters.Length == 1 && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.CompareToMethod.If(members.Any(member => diff --git a/DomainModeling.Tests/DomainModeling.Tests.csproj b/DomainModeling.Tests/DomainModeling.Tests.csproj index 12ff044..fd1abcb 100644 --- a/DomainModeling.Tests/DomainModeling.Tests.csproj +++ b/DomainModeling.Tests/DomainModeling.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 Architect.DomainModeling.Tests Architect.DomainModeling.Tests Enable diff --git a/DomainModeling/Configuration/IDomainEventConfigurator.cs b/DomainModeling/Configuration/IDomainEventConfigurator.cs index a54a783..3b005e4 100644 --- a/DomainModeling/Configuration/IDomainEventConfigurator.cs +++ b/DomainModeling/Configuration/IDomainEventConfigurator.cs @@ -16,7 +16,9 @@ void ConfigureDomainEvent< in Args args) where TDomainEvent : IDomainObject; +#pragma warning disable IDE0040 // Remove accessibility modifiers -- We always want explicit accessibility for types public readonly struct Args +#pragma warning restore IDE0040 // Remove accessibility modifiers { public readonly bool HasDefaultConstructor { get; init; } } diff --git a/DomainModeling/Configuration/IEntityConfigurator.cs b/DomainModeling/Configuration/IEntityConfigurator.cs index ef41295..c244a86 100644 --- a/DomainModeling/Configuration/IEntityConfigurator.cs +++ b/DomainModeling/Configuration/IEntityConfigurator.cs @@ -16,7 +16,9 @@ void ConfigureEntity< in Args args) where TEntity : IEntity; +#pragma warning disable IDE0040 // Remove accessibility modifiers -- We always want explicit accessibility for types public readonly struct Args +#pragma warning restore IDE0040 // Remove accessibility modifiers { public bool HasDefaultConstructor { get; init; } } diff --git a/DomainModeling/Configuration/IIdentityConfigurator.cs b/DomainModeling/Configuration/IIdentityConfigurator.cs index 152e2ab..4748f31 100644 --- a/DomainModeling/Configuration/IIdentityConfigurator.cs +++ b/DomainModeling/Configuration/IIdentityConfigurator.cs @@ -18,7 +18,9 @@ void ConfigureIdentity< where TIdentity : IIdentity, ISerializableDomainObject where TUnderlying : notnull, IEquatable, IComparable; +#pragma warning disable IDE0040 // Remove accessibility modifiers -- We always want explicit accessibility for types public readonly struct Args +#pragma warning restore IDE0040 // Remove accessibility modifiers { } } diff --git a/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs b/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs index c9c34b0..03cb4f5 100644 --- a/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs +++ b/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs @@ -18,7 +18,9 @@ void ConfigureWrapperValueObject< where TWrapper : IWrapperValueObject, ISerializableDomainObject where TValue : notnull; +#pragma warning disable IDE0040 // Remove accessibility modifiers -- We always want explicit accessibility for types public readonly struct Args +#pragma warning restore IDE0040 // Remove accessibility modifiers { } } diff --git a/DomainModeling/Conversions/DomainObjectSerializer.cs b/DomainModeling/Conversions/DomainObjectSerializer.cs index c89870d..4fbd1d9 100644 --- a/DomainModeling/Conversions/DomainObjectSerializer.cs +++ b/DomainModeling/Conversions/DomainObjectSerializer.cs @@ -8,12 +8,23 @@ 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); + [UnconditionalSuppressMessage("Trimming", "IL2111", Justification = "We rely only on public methods, which we take an explicit dependency on")] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(DomainObjectSerializer))] + private static readonly MethodInfo GenericDeserializeMethod = + typeof(DomainObjectSerializer).GetMethods(BindingFlags.Static | BindingFlags.Public) + .Single(method => method.Name == nameof(Deserialize) && method.GetParameters() is []); + + [UnconditionalSuppressMessage("Trimming", "IL2111", Justification = "We rely only on public methods, which we take an explicit dependency on")] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(DomainObjectSerializer))] + private static readonly MethodInfo GenericDeserializeFromValueMethod = + typeof(DomainObjectSerializer).GetMethods(BindingFlags.Static | BindingFlags.Public) + .Single(method => method.Name == nameof(Deserialize) && method.GetParameters().Length == 1); + + [UnconditionalSuppressMessage("Trimming", "IL2111", Justification = "We rely only on public methods, which we take an explicit dependency on")] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(DomainObjectSerializer))] + private static readonly MethodInfo GenericSerializeMethod = + typeof(DomainObjectSerializer).GetMethods(BindingFlags.Static | BindingFlags.Public) + .Single(method => method.Name == nameof(Serialize) && method.GetParameters().Length == 1); #region Deserialize empty @@ -37,6 +48,7 @@ public static class DomainObjectSerializer /// When evaluated, the expression deserializes an empty, uninitialized instance of the . /// /// + [UnconditionalSuppressMessage("Trimming", "IL2060", Justification = "We rely only on constructors, which we take an explicit dependency on")] public static Expression CreateDeserializeExpression([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType) { var method = GenericDeserializeMethod.MakeGenericMethod(modelType); @@ -113,6 +125,7 @@ public static Expression CreateDeserializeExpression([DynamicallyAccessedMembers return lambda; } + [UnconditionalSuppressMessage("Trimming", "IL2060", Justification = "We rely only on constructors, which we take an explicit dependency on")] private static MethodCallExpression CreateDeserializeExpressionCore([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType, Type underlyingType, out ParameterExpression parameter) { @@ -171,6 +184,7 @@ public static Expression CreateSerializeExpression([DynamicallyAccessedMembers(D return lambda; } + [UnconditionalSuppressMessage("Trimming", "IL2060", Justification = "We rely only on constructors, which we take an explicit dependency on")] private static MethodCallExpression CreateSerializeExpressionCore([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType, Type underlyingType, out ParameterExpression parameter) { diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index ebf8110..fb07427 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -1,13 +1,13 @@ - + - net8.0;net7.0;net6.0 + net9.0;net8.0 False Architect.DomainModeling Architect.DomainModeling Enable Enable - 11 + 12 True True @@ -25,6 +25,15 @@ https://github.com/TheArchitectDev/Architect.DomainModeling Release notes: +4.0.0: + +- BREAKING: Platform support: Dropped support for .NET 6.0 and .NET 7.0 (EOL). +- Semi-breaking: IIdentity now implements IWrapperValueObject. +- Bug fix: Fixed a bug where source-generated records would always generate ToString()/Equals()/GetHashCode(), even if you wrote your own. +- Buf fix: Fixed a bug where the DummyBuilder generator struggled with nested types. +- Bug fix: Fixed a compile-time bug where the analyzer would not properly warn that source generation on nested types is unsupported. +- Enhancement: Improved correctness of trimming. + 3.0.3: - Enhancement: Upgraded package versions. diff --git a/DomainModeling/IIdentity.cs b/DomainModeling/IIdentity.cs index 8404318..79124f6 100644 --- a/DomainModeling/IIdentity.cs +++ b/DomainModeling/IIdentity.cs @@ -8,7 +8,19 @@ namespace Architect.DomainModeling; /// This interface marks an identity type that wraps underlying type . /// /// -public interface IIdentity : IValueObject +public interface IIdentity : IIdentity, IWrapperValueObject where T : notnull, IEquatable, IComparable { } + +/// +/// +/// A specific used as an object's identity. +/// +/// +/// This interface marks an identity type that wraps a single value. +/// +/// +public interface IIdentity : IWrapperValueObject +{ +} diff --git a/DomainModeling/IValueObject.cs b/DomainModeling/IValueObject.cs index 79c7538..cf13288 100644 --- a/DomainModeling/IValueObject.cs +++ b/DomainModeling/IValueObject.cs @@ -1,4 +1,4 @@ -namespace Architect.DomainModeling; +namespace Architect.DomainModeling; /// /// @@ -7,9 +7,6 @@ /// /// Value objects are identified and compared by their values. /// -/// -/// Struct value objects should implement this interface, as they cannot inherit from . -/// /// public interface IValueObject : IDomainObject { diff --git a/DomainModeling/IWrapperValueObject.cs b/DomainModeling/IWrapperValueObject.cs index cbcf63b..36f4fde 100644 --- a/DomainModeling/IWrapperValueObject.cs +++ b/DomainModeling/IWrapperValueObject.cs @@ -7,11 +7,20 @@ namespace Architect.DomainModeling; /// /// Value objects are identified and compared by their values. /// +/// +public interface IWrapperValueObject : IWrapperValueObject + where TValue : notnull +{ +} + +/// /// -/// Struct value objects should implement this interface, as they cannot inherit from . +/// An wrapping a single value, i.e. an immutable data model representing a single value. +/// +/// +/// Value objects are identified and compared by their values. /// /// -public interface IWrapperValueObject : IValueObject - where TValue : notnull +public interface IWrapperValueObject : IValueObject { } From 5def944a35ea615464b4468cf16c2b5d6355a816 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Sun, 27 Jul 2025 15:11:55 +0200 Subject: [PATCH 02/23] Generic JSON serializers instead of generated. --- DomainModeling.Generator/IdentityGenerator.cs | 12 +- .../JsonSerializationGenerator.cs | 147 +----------------- .../WrapperValueObjectGenerator.cs | 12 +- DomainModeling.Tests/IdentityTests.cs | 36 +---- .../WrapperValueObjectTests.cs | 37 +---- .../NewtonsoftWrapperJsonConverter.cs | 99 ++++++++++++ .../Conversions/WrapperJsonConverter.cs | 115 ++++++++++++++ DomainModeling/DomainModeling.csproj | 3 +- 8 files changed, 231 insertions(+), 230 deletions(-) create mode 100644 DomainModeling/Conversions/NewtonsoftWrapperJsonConverter.cs create mode 100644 DomainModeling/Conversions/WrapperJsonConverter.cs diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index fb1f883..e343092 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -401,11 +401,11 @@ namespace {containingNamespace} {summary} {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteJsonConverterAttribute(idTypeName)} + {JsonSerializationGenerator.WriteJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(idTypeName)} + {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} {(hasIdentityValueObjectAttribute ? "" : $"[IdentityValueObject<{underlyingTypeFullyQualifiedName}>]")} @@ -606,14 +606,6 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid #endif #endregion - - {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteJsonConverter(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} - {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteNewtonsoftJsonConverter(idTypeName, underlyingTypeFullyQualifiedName, isStruct: true, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} - {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} }} }} "; diff --git a/DomainModeling.Generator/JsonSerializationGenerator.cs b/DomainModeling.Generator/JsonSerializationGenerator.cs index 5fc0919..fcea385 100644 --- a/DomainModeling.Generator/JsonSerializationGenerator.cs +++ b/DomainModeling.Generator/JsonSerializationGenerator.cs @@ -5,150 +5,15 @@ namespace Architect.DomainModeling.Generator; /// internal static class JsonSerializationGenerator { - public static string WriteJsonConverterAttribute(string modelTypeName) + public static string WriteJsonConverterAttribute(string modelTypeName, string underlyingTypeFullyQualifiedName, + bool numericAsString = false) { - return $"[System.Text.Json.Serialization.JsonConverter(typeof({modelTypeName}.JsonConverter))]"; + return $"[System.Text.Json.Serialization.JsonConverter(typeof({(numericAsString ? "LargeNumber" : "")}WrapperJsonConverter<{modelTypeName}, {underlyingTypeFullyQualifiedName}>))]"; } - public static string WriteNewtonsoftJsonConverterAttribute(string modelTypeName) + public static string WriteNewtonsoftJsonConverterAttribute(string modelTypeName, string underlyingTypeFullyQualifiedName, + bool numericAsString = false) { - 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; + return $"[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft{(numericAsString ? "LargeNumber" : "")}WrapperJsonConverter<{modelTypeName}, {underlyingTypeFullyQualifiedName}>))]"; } } diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index 328e3be..a2f9785 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -337,11 +337,11 @@ private static void GenerateSource(SourceProductionContext context, Generatable namespace {containingNamespace} {{ {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteJsonConverterAttribute(typeName)} + {JsonSerializationGenerator.WriteJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "*/" : "")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(typeName)} + {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} /* Generated */ {generatable.Accessibility.ToCodeString()} sealed partial{(generatable.IsRecord ? " record" : "")} class {typeName} @@ -569,14 +569,6 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid #endif #endregion - - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteJsonConverter(typeName, underlyingTypeFullyQualifiedName, numericAsString: false)} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "*/" : "")} - - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteNewtonsoftJsonConverter(typeName, underlyingTypeFullyQualifiedName, isStruct: false, numericAsString: false)} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} }} }} "; diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index 0fe996b..a5c6d98 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -748,8 +748,8 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly /// Should merely compile. /// [IdentityValueObject] - [System.Text.Json.Serialization.JsonConverter(typeof(JsonConverter))] - [Newtonsoft.Json.JsonConverter(typeof(NewtonsoftJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(WrapperJsonConverter))] + [Newtonsoft.Json.JsonConverter(typeof(NewtonsoftWrapperJsonConverter))] internal readonly partial struct FullySelfImplementedIdentity : IIdentity, IEquatable, @@ -872,38 +872,6 @@ public static FullySelfImplementedIdentity Parse(ReadOnlySpan utf8Text, IF #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) => - 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, DomainObjectSerializer.Serialize(value), options); - - 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) => - 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/WrapperValueObjectTests.cs b/DomainModeling.Tests/WrapperValueObjectTests.cs index a118725..4a5bde8 100644 --- a/DomainModeling.Tests/WrapperValueObjectTests.cs +++ b/DomainModeling.Tests/WrapperValueObjectTests.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Runtime.CompilerServices; using Architect.DomainModeling.Conversions; +using Architect.DomainModeling.Tests.IdentityTestTypes; using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; using Xunit; @@ -772,8 +773,8 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly /// Should merely compile. /// [WrapperValueObject] - [System.Text.Json.Serialization.JsonConverter(typeof(JsonConverter))] - [Newtonsoft.Json.JsonConverter(typeof(NewtonsoftJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(WrapperJsonConverter))] + [Newtonsoft.Json.JsonConverter(typeof(NewtonsoftWrapperJsonConverter))] internal sealed partial class FullySelfImplementedWrapperValueObject : WrapperValueObject, IComparable, @@ -906,38 +907,6 @@ public static FullySelfImplementedWrapperValueObject Parse(ReadOnlySpan ut #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) => - 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, DomainObjectSerializer.Serialize(value), options); - - 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) => - ((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) => - objectType == typeof(FullySelfImplementedWrapperValueObject); - - 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 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/Conversions/NewtonsoftWrapperJsonConverter.cs b/DomainModeling/Conversions/NewtonsoftWrapperJsonConverter.cs new file mode 100644 index 0000000..1ee05ab --- /dev/null +++ b/DomainModeling/Conversions/NewtonsoftWrapperJsonConverter.cs @@ -0,0 +1,99 @@ +using System.Diagnostics.CodeAnalysis; +using System.Numerics; + +namespace Architect.DomainModeling.Conversions; + +/// +/// A generic System.Text JSON converter for wrapper types, which serializes like the wrapped value itself. +/// +public sealed class NewtonsoftWrapperJsonConverter< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, + TValue> + : Newtonsoft.Json.JsonConverter + where TWrapper : ISerializableDomainObject +{ + private static readonly Type? NullableWrapperType = typeof(TWrapper).IsValueType + ? typeof(Nullable<>).MakeGenericType(typeof(TWrapper)) + : null; + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(TWrapper) || + (typeof(TWrapper).IsValueType && objectType == NullableWrapperType!); + } + + public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) + { + if (reader.Value is null && (!typeof(TWrapper).IsValueType || objectType != typeof(TWrapper))) // Null data for a reference type or nullable value type + return null; + + var value = serializer.Deserialize(reader)!; + return DomainObjectSerializer.Deserialize(value); + } + + public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) + { + var underlyingValue = value is not TWrapper instance + ? (object?)null + : DomainObjectSerializer.Serialize(instance); + serializer.Serialize(writer, underlyingValue); + } +} + +/// +/// A generic System.Text JSON converter for wrapper types around numerics, which serializes like the wrapped value itself. +/// This variant is intended for numeric types whose larger values risk truncation in languages such as JavaScript. +/// It serializes to and from string. +/// +public sealed class NewtonsoftLargeNumberWrapperJsonConverter< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, + TValue> + : Newtonsoft.Json.JsonConverter + where TWrapper : ISerializableDomainObject + where TValue : INumber, ISpanParsable, ISpanFormattable +{ + private static readonly Type? NullableWrapperType = typeof(TWrapper).IsValueType + ? typeof(Nullable<>).MakeGenericType(typeof(TWrapper)) + : null; + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(TWrapper) || + (typeof(TWrapper).IsValueType && objectType == NullableWrapperType!); + } + + public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) + { + if (reader.Value is null && (!typeof(TWrapper).IsValueType || objectType != typeof(TWrapper))) // Null data for a reference type or nullable value type + return null; + + // The longer numeric types are not JavaScript-safe, so treat them as strings + if (reader.TokenType == Newtonsoft.Json.JsonToken.String) + { + var stringValue = serializer.Deserialize(reader)!; + var value = TValue.Parse(stringValue, System.Globalization.CultureInfo.InvariantCulture); + return DomainObjectSerializer.Deserialize(value); + } + else + { + var value = serializer.Deserialize(reader)!; + return DomainObjectSerializer.Deserialize(value); + } + } + + public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) + { + // The longer numeric types are not JavaScript-safe, so treat them as strings + //serializer.Serialize(writer, value is not TWrapper instance ? (object?)null : instance.Value.ToString(""0.#"", System.Globalization.CultureInfo.InvariantCulture)); + + if (value is not TWrapper instance) + { + serializer.Serialize(writer, null); + return; + } + + var underlyingValue = DomainObjectSerializer.Serialize(instance)!; + var stringValue = underlyingValue.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture); + serializer.Serialize(writer, stringValue); + } +} diff --git a/DomainModeling/Conversions/WrapperJsonConverter.cs b/DomainModeling/Conversions/WrapperJsonConverter.cs new file mode 100644 index 0000000..0578dde --- /dev/null +++ b/DomainModeling/Conversions/WrapperJsonConverter.cs @@ -0,0 +1,115 @@ +using System.Diagnostics.CodeAnalysis; +using System.Numerics; + +namespace Architect.DomainModeling.Conversions; + +/// +/// A generic System.Text JSON converter for wrapper types, which serializes like the wrapped value itself. +/// +[UnconditionalSuppressMessage( + "Trimming", "IL2046", + Justification = "JsonConverter read/write methods are not marked with RequiresUnreferencedCode, but overrides require unreferenced code due to serialization." +)] +public sealed class WrapperJsonConverter< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, + TValue> + : System.Text.Json.Serialization.JsonConverter + where TWrapper : ISerializableDomainObject +{ + private const string RequiresUnreferencedCodeMessage = "Serialization requires unreferenced code."; + private const string RequiresDynamicCodeMessage = "Serialization requires dynamic code."; + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override TWrapper Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) + { + var value = System.Text.Json.JsonSerializer.Deserialize(ref reader, options)!; + return DomainObjectSerializer.Deserialize(value); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override void Write(System.Text.Json.Utf8JsonWriter writer, TWrapper value, System.Text.Json.JsonSerializerOptions options) + { + var serializedValue = DomainObjectSerializer.Serialize(value); + System.Text.Json.JsonSerializer.Serialize(writer, serializedValue, options); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override TWrapper ReadAsPropertyName(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) + { + var value = ((System.Text.Json.Serialization.JsonConverter)options.GetConverter(typeof(TValue))).ReadAsPropertyName(ref reader, typeToConvert, options)!; + return DomainObjectSerializer.Deserialize(value); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, TWrapper value, System.Text.Json.JsonSerializerOptions options) + { + var serializedValue = DomainObjectSerializer.Serialize(value)!; + ((System.Text.Json.Serialization.JsonConverter)options.GetConverter(typeof(TValue))).WriteAsPropertyName( + writer, + serializedValue, + options); + } +} + +/// +/// A generic System.Text JSON converter for wrapper types around numerics, which serializes like the wrapped value itself. +/// This variant is intended for numeric types whose larger values risk truncation in languages such as JavaScript. +/// It serializes to and from string. +/// +[UnconditionalSuppressMessage( + "Trimming", "IL2046", + Justification = "JsonConverter read/write methods are not marked with RequiresUnreferencedCode, but overrides require unreferenced code due to serialization." +)] +public sealed class LargeNumberWrapperJsonConverter< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, + TValue> + : System.Text.Json.Serialization.JsonConverter + where TWrapper : ISerializableDomainObject + where TValue : INumber, ISpanParsable, ISpanFormattable +{ + private const string RequiresUnreferencedCodeMessage = "Serialization requires unreferenced code."; + private const string RequiresDynamicCodeMessage = "Serialization requires dynamic code."; + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override TWrapper Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) + { + // The longer numeric types are not JavaScript-safe, so treat them as strings + var value = reader.TokenType == System.Text.Json.JsonTokenType.String + ? reader.GetParsedString(System.Globalization.CultureInfo.InvariantCulture) + : System.Text.Json.JsonSerializer.Deserialize(ref reader, options)!; + return DomainObjectSerializer.Deserialize(value); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override void Write(System.Text.Json.Utf8JsonWriter writer, TWrapper value, System.Text.Json.JsonSerializerOptions options) + { + // The longer numeric types are not JavaScript-safe, so treat them as strings + var serializedValue = DomainObjectSerializer.Serialize(value)!; + writer.WriteStringValue(serializedValue.Format(stackalloc char[64], "0.#", System.Globalization.CultureInfo.InvariantCulture)); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override TWrapper ReadAsPropertyName(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) + { + var value = ((System.Text.Json.Serialization.JsonConverter)options.GetConverter(typeof(TValue))).ReadAsPropertyName(ref reader, typeToConvert, options)!; + return DomainObjectSerializer.Deserialize(value); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, TWrapper value, System.Text.Json.JsonSerializerOptions options) + { + var serializedValue = DomainObjectSerializer.Serialize(value)!; + ((System.Text.Json.Serialization.JsonConverter)options.GetConverter(typeof(TValue))).WriteAsPropertyName( + writer, + serializedValue, + options); + } +} diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index fb07427..3244a94 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -29,10 +29,11 @@ Release notes: - BREAKING: Platform support: Dropped support for .NET 6.0 and .NET 7.0 (EOL). - Semi-breaking: IIdentity now implements IWrapperValueObject. +- Enhancement: Generated domain objects now use generic JSON serializers instead of generating their own, reducing assembly size. +- Enhancement: Improved correctness of trimming. - Bug fix: Fixed a bug where source-generated records would always generate ToString()/Equals()/GetHashCode(), even if you wrote your own. - Buf fix: Fixed a bug where the DummyBuilder generator struggled with nested types. - Bug fix: Fixed a compile-time bug where the analyzer would not properly warn that source generation on nested types is unsupported. -- Enhancement: Improved correctness of trimming. 3.0.3: From 2dbb80069a0c27fdfd7372380d6dbb91206934d8 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Sun, 27 Jul 2025 15:20:26 +0200 Subject: [PATCH 03/23] Removed #if NET7/8 conditionals, now that 8 is the minimum version. --- .../EntityFrameworkConfigurationGenerator.cs | 4 --- DomainModeling.Generator/IdentityGenerator.cs | 14 ---------- .../WrapperValueObjectGenerator.cs | 27 +++---------------- DomainModeling.Tests/IdentityTests.cs | 12 --------- .../WrapperValueObjectTests.cs | 12 --------- .../Conversions/DomainObjectSerializer.cs | 4 --- .../Conversions/FormattingExtensions.cs | 2 -- .../Conversions/FormattingHelper.cs | 8 ------ .../Conversions/ObjectInstantiator.cs | 4 --- DomainModeling/Conversions/ParsingHelper.cs | 8 ------ .../Conversions/Utf8JsonReaderExtensions.cs | 4 --- DomainModeling/ISerializableDomainObject.cs | 2 -- 12 files changed, 3 insertions(+), 98 deletions(-) diff --git a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs index f7df66b..a096fab 100644 --- a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs +++ b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs @@ -135,8 +135,6 @@ private static void GenerateSource(SourceProductionContext context, (Generatable 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; @@ -401,8 +399,6 @@ public override InstantiationBinding With(IReadOnlyList parame }} }} }} - -#endif "; AddSource(context, source, "EntityFrameworkDomainModelConfigurationExtensions", $"{Constants.DomainModelingNamespace}.EntityFramework"); diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index e343092..ffecab3 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -413,14 +413,10 @@ namespace {containingNamespace} : {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) ? "/*" : "")} @@ -493,7 +489,6 @@ public int CompareTo({idTypeName} other) {(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. /// @@ -503,7 +498,6 @@ public int CompareTo({idTypeName} other) {(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) ? "/*" : "")} @@ -546,8 +540,6 @@ public int CompareTo({idTypeName} other) #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); @@ -582,10 +574,6 @@ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [Ma ({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); @@ -603,8 +591,6 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid ({idTypeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(utf8Text, provider); {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableParseMethod) ? "*/" : "")} -#endif - #endregion }} }} diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index a2f9785..d0cf599 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -348,14 +348,10 @@ namespace {containingNamespace} : {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) ? "/*" : "")} @@ -439,13 +435,9 @@ public int CompareTo({typeName}? other) {(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 + private static extern ref {underlyingTypeFullyQualifiedName} GetValueFieldReference({typeName} instance);" : "")} + /// /// Deserializes a plain value back into a domain object, without any validation. /// @@ -453,16 +445,11 @@ public int CompareTo({typeName}? other) {{ {(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" : "")} + var result = new {typeName}(); GetValueFieldReference(result) = value; return result;" : "")} #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) ? "/*" : "")} @@ -509,8 +496,6 @@ public int CompareTo({typeName}? other) #region Formatting & Parsing -#if NET7_0_OR_GREATER - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.FormattableToStringOverride) ? "/*" : "")} public string ToString(string? format, IFormatProvider? formatProvider) => FormattingHelper.ToString(this.Value, format, formatProvider); @@ -545,10 +530,6 @@ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [Ma ({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); @@ -566,8 +547,6 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid ({typeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(utf8Text, provider); {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanParsableParseMethod) ? "*/" : "")} -#endif - #endregion }} }} diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index a5c6d98..8de5fe7 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -754,14 +754,10 @@ 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; private init; } @@ -830,8 +826,6 @@ static FullySelfImplementedIdentity ISerializableDomainObject FormattingHelper.ToString(this.Value, format, formatProvider); @@ -854,10 +848,6 @@ public static FullySelfImplementedIdentity Parse(string s, IFormatProvider? prov 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); @@ -869,8 +859,6 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid public static FullySelfImplementedIdentity Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => (FullySelfImplementedIdentity)ParsingHelper.Parse(utf8Text, provider); -#endif - #endregion } } diff --git a/DomainModeling.Tests/WrapperValueObjectTests.cs b/DomainModeling.Tests/WrapperValueObjectTests.cs index 4a5bde8..0c577d3 100644 --- a/DomainModeling.Tests/WrapperValueObjectTests.cs +++ b/DomainModeling.Tests/WrapperValueObjectTests.cs @@ -778,14 +778,10 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly 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."); @@ -865,8 +861,6 @@ static FullySelfImplementedWrapperValueObject ISerializableDomainObject FormattingHelper.ToString(this.Value, format, formatProvider); @@ -889,10 +883,6 @@ public static FullySelfImplementedWrapperValueObject Parse(string s, IFormatProv 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); @@ -904,8 +894,6 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid public static FullySelfImplementedWrapperValueObject Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => (FullySelfImplementedWrapperValueObject)ParsingHelper.Parse(utf8Text, provider); -#endif - #endregion } } diff --git a/DomainModeling/Conversions/DomainObjectSerializer.cs b/DomainModeling/Conversions/DomainObjectSerializer.cs index 4fbd1d9..65cc325 100644 --- a/DomainModeling/Conversions/DomainObjectSerializer.cs +++ b/DomainModeling/Conversions/DomainObjectSerializer.cs @@ -1,5 +1,3 @@ -#if NET7_0_OR_GREATER - using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; @@ -196,5 +194,3 @@ private static MethodCallExpression CreateSerializeExpressionCore([DynamicallyAc #endregion } - -#endif diff --git a/DomainModeling/Conversions/FormattingExtensions.cs b/DomainModeling/Conversions/FormattingExtensions.cs index 5ca909f..dddd38e 100644 --- a/DomainModeling/Conversions/FormattingExtensions.cs +++ b/DomainModeling/Conversions/FormattingExtensions.cs @@ -5,7 +5,6 @@ namespace Architect.DomainModeling.Conversions; /// public static class FormattingExtensions { -#if NET7_0_OR_GREATER /// /// /// Formats the into the provided , returning the segment that was written to. @@ -26,5 +25,4 @@ public static ReadOnlySpan Format(this T value, Span buffer, Read return buffer[..charCount]; } -#endif } diff --git a/DomainModeling/Conversions/FormattingHelper.cs b/DomainModeling/Conversions/FormattingHelper.cs index e1deabe..440824a 100644 --- a/DomainModeling/Conversions/FormattingHelper.cs +++ b/DomainModeling/Conversions/FormattingHelper.cs @@ -15,8 +15,6 @@ namespace Architect.DomainModeling.Conversions; /// 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. @@ -117,10 +115,6 @@ public static bool TryFormat(string? instance, } #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. @@ -172,6 +166,4 @@ public static bool TryFormat(string instance, 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 index 02319bc..335bdd0 100644 --- a/DomainModeling/Conversions/ObjectInstantiator.cs +++ b/DomainModeling/Conversions/ObjectInstantiator.cs @@ -27,12 +27,8 @@ static ObjectInstantiator() } 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 { diff --git a/DomainModeling/Conversions/ParsingHelper.cs b/DomainModeling/Conversions/ParsingHelper.cs index f444f37..7760841 100644 --- a/DomainModeling/Conversions/ParsingHelper.cs +++ b/DomainModeling/Conversions/ParsingHelper.cs @@ -16,8 +16,6 @@ namespace Architect.DomainModeling.Conversions; /// 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. @@ -98,10 +96,6 @@ public static T Parse(ReadOnlySpan s, IFormatProvider? provider) 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 /// /// @@ -170,6 +164,4 @@ public static T Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) { return T.Parse(utf8Text, provider); } - -#endif } diff --git a/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs b/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs index 2c4db50..b1d33cf 100644 --- a/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs +++ b/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs @@ -9,7 +9,6 @@ namespace Architect.DomainModeling.Conversions; /// public static class Utf8JsonReaderExtensions { -#if NET7_0_OR_GREATER /// /// Reads the next string JSON token from the source and parses it as , which must implement . /// @@ -37,9 +36,7 @@ public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? var result = T.Parse(chars, provider); return result; } -#endif -#if NET8_0_OR_GREATER /// /// Reads the next string JSON token from the source and parses it as , which must implement . /// @@ -69,5 +66,4 @@ public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? var result = T.Parse(chars, provider); return result; } -#endif } diff --git a/DomainModeling/ISerializableDomainObject.cs b/DomainModeling/ISerializableDomainObject.cs index 4e96be4..735823b 100644 --- a/DomainModeling/ISerializableDomainObject.cs +++ b/DomainModeling/ISerializableDomainObject.cs @@ -14,10 +14,8 @@ public interface ISerializableDomainObject< /// TUnderlying? Serialize(); -#if NET7_0_OR_GREATER /// /// Deserializes a from a . /// abstract static TModel Deserialize(TUnderlying value); -#endif } From 898c491428eddc323227a481d6aebc8534580acb Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Sun, 27 Jul 2025 15:40:05 +0200 Subject: [PATCH 04/23] Upgraded LangVersion and handled compiler suggestions. --- .editorconfig | 1 + .../DomainModeling.Example.csproj | 2 +- DomainModeling.Example/Program.cs | 6 ++-- .../DomainModeling.Generator.csproj | 2 +- .../Comparisons/DictionaryComparerTests.cs | 16 +++++----- .../Comparisons/EnumerableComparerTests.cs | 30 ++++++++++--------- .../Comparisons/LookupComparerTests.cs | 16 +++++----- DomainModeling.Tests/ValueObjectTests.cs | 12 ++++---- .../Attributes/DomainEventAttribute.cs | 1 + .../Attributes/DummyBuilderAttribute.cs | 1 + DomainModeling/Attributes/EntityAttribute.cs | 1 + .../IdentityValueObjectAttribute.cs | 1 + .../Attributes/SourceGeneratedAttribute.cs | 1 + .../Attributes/ValueObjectAttribute.cs | 1 + .../Attributes/WrapperValueObjectAttribute.cs | 1 + .../Conversions/ObjectInstantiator.cs | 2 +- .../Conversions/Utf8JsonReaderExtensions.cs | 4 +++ DomainModeling/DomainModeling.csproj | 8 ++++- 18 files changed, 64 insertions(+), 42 deletions(-) diff --git a/.editorconfig b/.editorconfig index 54ac977..673f169 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,6 +21,7 @@ dotnet_diagnostic.CA1822.severity = none # CA1822: Instance member does not acce dotnet_diagnostic.CS1573.severity = none # CS1573: Undocumented public symbol while -doc compiler option is used dotnet_diagnostic.CS1591.severity = none # CS1591: Missing XML comment for publicly visible type dotnet_diagnostic.CA1816.severity = none # CA1816: Dispose() should call GC.SuppressFinalize() +dotnet_diagnostic.IDE0305.severity = silent # IDE0305: Collection initialization can be simplified -- spoils chained LINQ calls (https://github.com/dotnet/roslyn/issues/70833) # Indentation and spacing indent_size = 4 diff --git a/DomainModeling.Example/DomainModeling.Example.csproj b/DomainModeling.Example/DomainModeling.Example.csproj index b8f0085..806a8b1 100644 --- a/DomainModeling.Example/DomainModeling.Example.csproj +++ b/DomainModeling.Example/DomainModeling.Example.csproj @@ -9,7 +9,7 @@ Enable False True - 12 + 13 diff --git a/DomainModeling.Example/Program.cs b/DomainModeling.Example/Program.cs index 671f965..cc8cce0 100644 --- a/DomainModeling.Example/Program.cs +++ b/DomainModeling.Example/Program.cs @@ -82,9 +82,9 @@ public static void Main() { Console.WriteLine("Demonstrating structural equality for collections:"); - var abc = new CharacterSet(new[] { 'a', 'b', 'c', }); - var abcd = new CharacterSet(new[] { 'a', 'b', 'c', 'd', }); - var abcClone = new CharacterSet(new[] { 'a', 'b', 'c', }); + var abc = new CharacterSet([ 'a', 'b', 'c', ]); + var abcd = new CharacterSet([ 'a', 'b', 'c', 'd', ]); + var abcClone = new CharacterSet([ 'a', 'b', 'c', ]); Console.WriteLine($"{abc == abcd}: {abc} == {abcd} (different values)"); Console.WriteLine($"{abc == abcClone}: {abc} == {abcClone} (different instances, same values in collection)"); // ValueObjects have structural equality diff --git a/DomainModeling.Generator/DomainModeling.Generator.csproj b/DomainModeling.Generator/DomainModeling.Generator.csproj index 8c90020..0b2cee9 100644 --- a/DomainModeling.Generator/DomainModeling.Generator.csproj +++ b/DomainModeling.Generator/DomainModeling.Generator.csproj @@ -6,7 +6,7 @@ Architect.DomainModeling.Generator Enable Enable - 12 + 13 False True True diff --git a/DomainModeling.Tests/Comparisons/DictionaryComparerTests.cs b/DomainModeling.Tests/Comparisons/DictionaryComparerTests.cs index ad81816..cbd6ebd 100644 --- a/DomainModeling.Tests/Comparisons/DictionaryComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/DictionaryComparerTests.cs @@ -64,8 +64,8 @@ public void DictionaryEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpect [Fact] public void DictionaryEquals_WithoutTwoWayEquality_ShouldReturnExpectedResult() { - var left = CreateDictionaryWithEqualityComparer(new[] { "A", "a", }, StringComparer.Ordinal); - var right = CreateDictionaryWithEqualityComparer(new[] { "A", }, StringComparer.Ordinal); + var left = CreateDictionaryWithEqualityComparer(["A", "a",], StringComparer.Ordinal); + var right = CreateDictionaryWithEqualityComparer(["A",], StringComparer.Ordinal); if (left is null || right is null) return; // Implementation does not support custom comparer @@ -80,8 +80,8 @@ public void DictionaryEquals_WithoutTwoWayEquality_ShouldReturnExpectedResult() [Fact] public void DictionaryEquals_WithIgnoreCaseWithTwoWayEquality_ShouldReturnExpectedResult() { - var left = CreateDictionaryWithEqualityComparer(new[] { "A", "a", }, StringComparer.OrdinalIgnoreCase); - var right = CreateDictionaryWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase); + var left = CreateDictionaryWithEqualityComparer(["A", "a",], StringComparer.OrdinalIgnoreCase); + var right = CreateDictionaryWithEqualityComparer(["A",], StringComparer.OrdinalIgnoreCase); if (left is null || right is null) return; // Implementation does not support custom comparer @@ -95,8 +95,8 @@ public void DictionaryEquals_WithIgnoreCaseWithTwoWayEquality_ShouldReturnExpect [Fact] public void DictionaryEquals_WithDifferentCaseComparersWithoutTwoWayEquality_ShouldReturnExpectedResult() { - var left = CreateDictionaryWithEqualityComparer(new[] { "a", }, StringComparer.Ordinal); - var right = CreateDictionaryWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase); + var left = CreateDictionaryWithEqualityComparer(["a",], StringComparer.Ordinal); + var right = CreateDictionaryWithEqualityComparer(["A",], StringComparer.OrdinalIgnoreCase); if (left is null || right is null) return; // Implementation does not support custom comparer @@ -111,8 +111,8 @@ public void DictionaryEquals_WithDifferentCaseComparersWithoutTwoWayEquality_Sho [Fact] public void DictionaryEquals_WithDifferentCaseComparersWithTwoWayEquality_ShouldReturnExpectedResult() { - var left = CreateDictionaryWithEqualityComparer(new[] { "A", "a", }, StringComparer.Ordinal); - var right = CreateDictionaryWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase); + var left = CreateDictionaryWithEqualityComparer(["A", "a",], StringComparer.Ordinal); + var right = CreateDictionaryWithEqualityComparer(["A",], StringComparer.OrdinalIgnoreCase); if (left is null || right is null) return; // Implementation does not support custom comparer diff --git a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs index bdddd89..41f3ca2 100644 --- a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs @@ -25,11 +25,13 @@ public sealed class ImmutableArrayComparerTests : EnumerableComparerTests public sealed class CustomListComparerTests : EnumerableComparerTests { +#pragma warning disable IDE0028 // Simplify collection initialization -- Want to use custom type protected override IEnumerable CreateCollectionCore(IEnumerable elements) => new CustomList(elements.ToList()); +#pragma warning restore IDE0028 // Simplify collection initialization private sealed class CustomList : IList { - private IList WrappedList { get; } = new List(); + private IList WrappedList { get; } = []; public T this[int index] { get => this.WrappedList[index]; @@ -88,7 +90,7 @@ public sealed class CustomReadOnlyCollectionComparerTests : EnumerableComparerTe private sealed class CustomReadOnlyCollection : IReadOnlyCollection { - private IList WrappedList { get; } = new List(); + private IList WrappedList { get; } = []; public int Count => this.WrappedList.Count; public IEnumerator GetEnumerator() => this.WrappedList.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); @@ -106,7 +108,7 @@ public sealed class CustomEnumerableComparerTests : EnumerableComparerTests private sealed class CustomEnumerable : IEnumerable { - private IList WrappedList { get; } = new List(); + private IList WrappedList { get; } = []; public IEnumerator GetEnumerator() => this.WrappedList.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); @@ -124,7 +126,7 @@ public abstract class EnumerableComparerTests protected IEnumerable CreateCollection(T singleElement) { - return this.CreateCollectionCore(new[] { singleElement }); + return this.CreateCollectionCore([singleElement]); } protected virtual IEnumerable? CreateCollectionWithEqualityComparer(IEnumerable elements, IComparer comparer) @@ -258,8 +260,8 @@ public void EnumerableEquals_WithStringIdentities_ShouldReturnExpectedResult(str [InlineData("A", "AA", false)] 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); + var left = this.CreateCollectionWithEqualityComparer([one], StringComparer.OrdinalIgnoreCase); + var right = this.CreateCollectionWithEqualityComparer([two], StringComparer.OrdinalIgnoreCase); if (left is null || right is null) return; // Implementation does not support custom comparer @@ -274,8 +276,8 @@ public void EnumerableEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpect [Fact] public void EnumerableEquals_WithoutTwoWayEquality_ShouldReturnExpectedResult() { - var left = this.CreateCollectionWithEqualityComparer(new[] { "A", "a", }, StringComparer.Ordinal); - var right = this.CreateCollectionWithEqualityComparer(new[] { "A", }, StringComparer.Ordinal); + var left = this.CreateCollectionWithEqualityComparer(["A", "a",], StringComparer.Ordinal); + var right = this.CreateCollectionWithEqualityComparer(["A",], StringComparer.Ordinal); if (left is null || right is null) return; // Implementation does not support custom comparer @@ -290,8 +292,8 @@ public void EnumerableEquals_WithoutTwoWayEquality_ShouldReturnExpectedResult() [Fact] public void EnumerableEquals_WithIgnoreCaseWithTwoWayEquality_ShouldReturnExpectedResult() { - var left = this.CreateCollectionWithEqualityComparer(new[] { "A", "a", }, StringComparer.OrdinalIgnoreCase); - var right = this.CreateCollectionWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase); + var left = this.CreateCollectionWithEqualityComparer(["A", "a",], StringComparer.OrdinalIgnoreCase); + var right = this.CreateCollectionWithEqualityComparer(["A",], StringComparer.OrdinalIgnoreCase); if (left is null || right is null) return; // Implementation does not support custom comparer @@ -304,8 +306,8 @@ public void EnumerableEquals_WithIgnoreCaseWithTwoWayEquality_ShouldReturnExpect [Fact] public void EnumerableEquals_WithDifferentCaseComparersWithoutTwoWayEquality_ShouldReturnExpectedResult() { - var left = this.CreateCollectionWithEqualityComparer(new[] { "a", }, StringComparer.Ordinal); - var right = this.CreateCollectionWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase); + var left = this.CreateCollectionWithEqualityComparer(["a",], StringComparer.Ordinal); + var right = this.CreateCollectionWithEqualityComparer(["A",], StringComparer.OrdinalIgnoreCase); if (left is null || right is null) return; // Implementation does not support custom comparer @@ -320,8 +322,8 @@ public void EnumerableEquals_WithDifferentCaseComparersWithoutTwoWayEquality_Sho [Fact] public void EnumerableEquals_WithDifferentCaseComparersWithTwoWayEquality_ShouldReturnExpectedResult() { - var left = this.CreateCollectionWithEqualityComparer(new[] { "A", "a", }, StringComparer.Ordinal); - var right = this.CreateCollectionWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase); + var left = this.CreateCollectionWithEqualityComparer(["A", "a",], StringComparer.Ordinal); + var right = this.CreateCollectionWithEqualityComparer(["A",], StringComparer.OrdinalIgnoreCase); if (left is null || right is null) return; // Implementation does not support custom comparer diff --git a/DomainModeling.Tests/Comparisons/LookupComparerTests.cs b/DomainModeling.Tests/Comparisons/LookupComparerTests.cs index 91dc5f0..fa7bc56 100644 --- a/DomainModeling.Tests/Comparisons/LookupComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/LookupComparerTests.cs @@ -69,8 +69,8 @@ public void LookupEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpectedRe [Fact] public void LookupEquals_WithoutTwoWayEquality_ShouldReturnExpectedResult() { - var left = CreateLookupWithEqualityComparer(new[] { "A", "a", }, StringComparer.Ordinal); - var right = CreateLookupWithEqualityComparer(new[] { "A", }, StringComparer.Ordinal); + var left = CreateLookupWithEqualityComparer(["A", "a",], StringComparer.Ordinal); + var right = CreateLookupWithEqualityComparer(["A",], StringComparer.Ordinal); if (left is null || right is null) return; // Implementation does not support custom comparer @@ -84,8 +84,8 @@ public void LookupEquals_WithoutTwoWayEquality_ShouldReturnExpectedResult() [Fact] public void LookupEquals_WithIgnoreCaseWithTwoWayEquality_ShouldReturnExpectedResult() { - var left = CreateLookupWithEqualityComparer(new[] { "A", "a", }, StringComparer.OrdinalIgnoreCase); - var right = CreateLookupWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase); + var left = CreateLookupWithEqualityComparer(["A", "a",], StringComparer.OrdinalIgnoreCase); + var right = CreateLookupWithEqualityComparer(["A",], StringComparer.OrdinalIgnoreCase); if (left is null || right is null) return; // Implementation does not support custom comparer @@ -98,8 +98,8 @@ public void LookupEquals_WithIgnoreCaseWithTwoWayEquality_ShouldReturnExpectedRe [Fact] public void LookupEquals_WithDifferentCaseComparersWithoutTwoWayEquality_ShouldReturnExpectedResult() { - var left = CreateLookupWithEqualityComparer(new[] { "a", }, StringComparer.Ordinal); - var right = CreateLookupWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase); + var left = CreateLookupWithEqualityComparer(["a",], StringComparer.Ordinal); + var right = CreateLookupWithEqualityComparer(["A",], StringComparer.OrdinalIgnoreCase); if (left is null || right is null) return; // Implementation does not support custom comparer @@ -113,8 +113,8 @@ public void LookupEquals_WithDifferentCaseComparersWithoutTwoWayEquality_ShouldR [Fact] public void LookupEquals_WithDifferentCaseComparersWithTwoWayEquality_ShouldReturnExpectedResult() { - var left = CreateLookupWithEqualityComparer(new[] { "A", "a", }, StringComparer.Ordinal); - var right = CreateLookupWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase); + var left = CreateLookupWithEqualityComparer(["A", "a",], StringComparer.Ordinal); + var right = CreateLookupWithEqualityComparer(["A",], StringComparer.OrdinalIgnoreCase); if (left is null || right is null) return; // Implementation does not support custom comparer diff --git a/DomainModeling.Tests/ValueObjectTests.cs b/DomainModeling.Tests/ValueObjectTests.cs index 8e661ac..e5718af 100644 --- a/DomainModeling.Tests/ValueObjectTests.cs +++ b/DomainModeling.Tests/ValueObjectTests.cs @@ -549,11 +549,11 @@ public void GetHashCode_WithIgnoreCaseString_ShouldReturnExpectedResult() [Fact] public void GetHashCode_WithImmutableArray_ShouldReturnExpectedResult() { - var one = new ImmutableArrayValueObject(new[] { "A" }).GetHashCode(); - var two = new ImmutableArrayValueObject(new[] { "A" }).GetHashCode(); + var one = new ImmutableArrayValueObject(["A"]).GetHashCode(); + var two = new ImmutableArrayValueObject(["A"]).GetHashCode(); Assert.Equal(one, two); - var three = new ImmutableArrayValueObject(new[] { "a" }).GetHashCode(); + var three = new ImmutableArrayValueObject(["a"]).GetHashCode(); Assert.NotEqual(one, three); // Note that the collection elements define their own GetHashCode() and do not care about the parent ValueObject's StringComparison value, by design } @@ -628,8 +628,8 @@ public void Equals_WithIgnoreCaseString_ShouldReturnExpectedResult(string one, s [InlineData("A", "B", false)] public void Equals_WithImmutableArray_ShouldReturnExpectedResult(string one, string two, bool expectedResult) { - var left = new ImmutableArrayValueObject(new[] { one }); - var right = new ImmutableArrayValueObject(new[] { two }); + var left = new ImmutableArrayValueObject([one]); + var right = new ImmutableArrayValueObject([two]); Assert.Equal(expectedResult, left.Equals(right)); Assert.Equal(expectedResult, right.Equals(left)); } @@ -810,6 +810,7 @@ public void ComparisonOperators_WithNullValueVsNull_ShouldReturnExpectedResult() { var nullValued = new DefaultComparingStringValue(value: null); +#pragma warning disable IDE0079 // Remove unnecessary suppressions -- The suppression below is often wrongfully flagged as unnecessary #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); @@ -824,6 +825,7 @@ public void ComparisonOperators_WithNullValueVsNull_ShouldReturnExpectedResult() Assert.True(nullValued > null); Assert.True(nullValued >= null); #pragma warning restore xUnit2024 // Do not use boolean asserts for simple equality tests +#pragma warning restore IDE0079 // Remove unnecessary suppressions } [Theory] diff --git a/DomainModeling/Attributes/DomainEventAttribute.cs b/DomainModeling/Attributes/DomainEventAttribute.cs index 9b130d4..27dd649 100644 --- a/DomainModeling/Attributes/DomainEventAttribute.cs +++ b/DomainModeling/Attributes/DomainEventAttribute.cs @@ -1,3 +1,4 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure namespace Architect.DomainModeling; /// diff --git a/DomainModeling/Attributes/DummyBuilderAttribute.cs b/DomainModeling/Attributes/DummyBuilderAttribute.cs index b2b0414..0e5ee7b 100644 --- a/DomainModeling/Attributes/DummyBuilderAttribute.cs +++ b/DomainModeling/Attributes/DummyBuilderAttribute.cs @@ -1,3 +1,4 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure namespace Architect.DomainModeling; /// diff --git a/DomainModeling/Attributes/EntityAttribute.cs b/DomainModeling/Attributes/EntityAttribute.cs index 7df1fce..761de67 100644 --- a/DomainModeling/Attributes/EntityAttribute.cs +++ b/DomainModeling/Attributes/EntityAttribute.cs @@ -1,3 +1,4 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure namespace Architect.DomainModeling; /// diff --git a/DomainModeling/Attributes/IdentityValueObjectAttribute.cs b/DomainModeling/Attributes/IdentityValueObjectAttribute.cs index 092ef95..e3db26f 100644 --- a/DomainModeling/Attributes/IdentityValueObjectAttribute.cs +++ b/DomainModeling/Attributes/IdentityValueObjectAttribute.cs @@ -1,3 +1,4 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure namespace Architect.DomainModeling; /// diff --git a/DomainModeling/Attributes/SourceGeneratedAttribute.cs b/DomainModeling/Attributes/SourceGeneratedAttribute.cs index b8ffb49..e567443 100644 --- a/DomainModeling/Attributes/SourceGeneratedAttribute.cs +++ b/DomainModeling/Attributes/SourceGeneratedAttribute.cs @@ -1,3 +1,4 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure namespace Architect.DomainModeling; /// diff --git a/DomainModeling/Attributes/ValueObjectAttribute.cs b/DomainModeling/Attributes/ValueObjectAttribute.cs index af84ac7..fd1ba8b 100644 --- a/DomainModeling/Attributes/ValueObjectAttribute.cs +++ b/DomainModeling/Attributes/ValueObjectAttribute.cs @@ -1,3 +1,4 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure namespace Architect.DomainModeling; /// diff --git a/DomainModeling/Attributes/WrapperValueObjectAttribute.cs b/DomainModeling/Attributes/WrapperValueObjectAttribute.cs index 4bccf04..7cc651e 100644 --- a/DomainModeling/Attributes/WrapperValueObjectAttribute.cs +++ b/DomainModeling/Attributes/WrapperValueObjectAttribute.cs @@ -1,3 +1,4 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure namespace Architect.DomainModeling; /// diff --git a/DomainModeling/Conversions/ObjectInstantiator.cs b/DomainModeling/Conversions/ObjectInstantiator.cs index 335bdd0..654dea8 100644 --- a/DomainModeling/Conversions/ObjectInstantiator.cs +++ b/DomainModeling/Conversions/ObjectInstantiator.cs @@ -25,7 +25,7 @@ static ObjectInstantiator() { 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) + else if (typeof(T).GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, binder: null, [], modifiers: null) is ConstructorInfo ctor) { var invoker = ConstructorInvoker.Create(ctor); ConstructionFunction = () => (T)invoker.Invoke(); diff --git a/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs b/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs index b1d33cf..df15782 100644 --- a/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs +++ b/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs @@ -19,7 +19,9 @@ public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? [CallerLineNumber] int callerLineNumber = -1) where T : ISpanParsable { +#pragma warning disable IDE0302 // Simplify collection initialization -- Analyzer fails to see that that does not work here ReadOnlySpan chars = stackalloc char[0]; +#pragma warning restore IDE0302 // Simplify collection initialization var maxCharLength = reader.HasValueSequence ? reader.ValueSequence.Length : reader.ValueSpan.Length; if (maxCharLength > 2048) // Avoid oversized stack allocations @@ -45,9 +47,11 @@ public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? provider) where T : IUtf8SpanParsable { +#pragma warning disable IDE0302 // Simplify collection initialization -- Analyzer fails to see that that does not work here ReadOnlySpan chars = reader.HasValueSequence ? stackalloc byte[0] : reader.ValueSpan; +#pragma warning restore IDE0302 // Simplify collection initialization if (reader.HasValueSequence) { diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 3244a94..0c3d5bc 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -5,12 +5,18 @@ False Architect.DomainModeling Architect.DomainModeling + True Enable Enable - 12 + 13 True True + + + + IDE0290 + From b53bf34bfa0090001af78c9b0cdfee1597393890 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:34:49 +0200 Subject: [PATCH 05/23] Suppressions and summary corrections. --- .../EntityFrameworkConfigurationGeneratorTests.cs | 12 +++++++----- DomainModeling/Comparisons/DictionaryComparer.cs | 4 ++++ DomainModeling/ISerializableDomainObject.cs | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs index 67027c0..4f0c9f6 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 constructor as used or not. + /// This lets us test if a constructor is 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 constructor as used or not. + /// This lets us test if a constructor is used or not. /// public bool HasFieldInitializerRun { get; } = true; @@ -146,12 +146,14 @@ public EntityForEF(ValueObjectForEF values) this.Values = values; } +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary #pragma warning disable CS8618 // Reconstitution constructor private EntityForEF() : base(default) { } #pragma warning restore CS8618 +#pragma warning restore IDE0079 } [WrapperValueObject] @@ -160,7 +162,7 @@ internal sealed partial class Wrapper1ForEF protected override StringComparison StringComparison => StringComparison.Ordinal; /// - /// This lets us test if a constructor as used or not. + /// This lets us test if a constructor is used or not. /// public bool HasFieldInitializerRun { get; } = true; @@ -177,7 +179,7 @@ public Wrapper1ForEF(string value) internal sealed partial class Wrapper2ForEF { /// - /// This lets us test if a constructor as used or not. + /// This lets us test if a constructor is used or not. /// public bool HasFieldInitializerRun { get; } = true; @@ -194,7 +196,7 @@ public Wrapper2ForEF(decimal value) internal sealed partial class ValueObjectForEF { /// - /// This lets us test if a constructor as used or not. + /// This lets us test if a constructor is used or not. /// public bool HasFieldInitializerRun = true; diff --git a/DomainModeling/Comparisons/DictionaryComparer.cs b/DomainModeling/Comparisons/DictionaryComparer.cs index bfa5825..6872934 100644 --- a/DomainModeling/Comparisons/DictionaryComparer.cs +++ b/DomainModeling/Comparisons/DictionaryComparer.cs @@ -76,10 +76,12 @@ public static int GetDictionaryHashCode(Dictionary? public static bool DictionaryEquals(IReadOnlyDictionary? left, IReadOnlyDictionary? right) { // Devirtualized path for practically all dictionaries +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary #pragma warning disable CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. -- Was type-checked if (left is Dictionary leftDict && right is Dictionary rightDict) return DictionaryEquals(leftDict, rightDict); #pragma warning restore CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. +#pragma warning restore IDE0079 return GetResult(left, right); @@ -115,10 +117,12 @@ static bool GetResult(IReadOnlyDictionary? left, IReadOnlyDictiona public static bool DictionaryEquals(IDictionary? left, IDictionary? right) { // Devirtualized path for practically all dictionaries +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary #pragma warning disable CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. -- Was type-checked if (left is Dictionary leftDict && right is Dictionary rightDict) return DictionaryEquals(leftDict, rightDict); #pragma warning restore CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. +#pragma warning restore IDE0079 return GetResult(left, right); diff --git a/DomainModeling/ISerializableDomainObject.cs b/DomainModeling/ISerializableDomainObject.cs index 735823b..0cae9b8 100644 --- a/DomainModeling/ISerializableDomainObject.cs +++ b/DomainModeling/ISerializableDomainObject.cs @@ -3,7 +3,7 @@ namespace Architect.DomainModeling; /// -/// An of type that can be serialized and deserialized to underlying type . +/// A domain object of type that can be serialized to and deserialized from underlying type . /// public interface ISerializableDomainObject< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, From a60110f85f8ca574e5b60bffae3f32ef28a78be9 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:03:17 +0200 Subject: [PATCH 06/23] Implemented formatting/parsing via default interface implementations and (.NET 10+) extension members. Prepared outcommented precompiler directives for a .NET 10 patch upgrade, to reduce assembly bloat then. Also corrected how formattable/parsable is determined based on wrapped types. Also fixed a few bugs. --- .../DomainModeling.Example.csproj | 4 +- ...inModelConfiguratorGenerator.Identities.cs | 6 +- ...nfiguratorGenerator.WrapperValueObjects.cs | 4 +- .../EntityFrameworkConfigurationGenerator.cs | 2 +- .../DomainEventGenerator.cs | 4 +- .../DummyBuilderGenerator.cs | 4 +- DomainModeling.Generator/EntityGenerator.cs | 4 +- ...eneratable.cs => GeneratableExtensions.cs} | 13 - DomainModeling.Generator/IdentityGenerator.cs | 305 ++++++++++---- .../JsonSerializationGenerator.cs | 4 +- DomainModeling.Generator/SymbolExtensions.cs | 28 ++ .../ValueObjectGenerator.cs | 4 +- .../ValueWrapperGenerator.cs | 104 +++++ .../WrapperValueObjectGenerator.cs | 389 +++++++++++------- .../DomainModeling.Tests.csproj | 3 +- DomainModeling.Tests/IdentityTests.cs | 68 ++- .../WrapperValueObjectTests.cs | 75 +++- .../Conversions/FormattingHelper.cs | 30 +- .../Conversions/IFormattableWrapper.cs | 135 ++++++ .../Conversions/IParsableWrapper.cs | 150 +++++++ DomainModeling/Conversions/IValueWrapper.cs | 38 ++ DomainModeling/Conversions/ParsingHelper.cs | 3 + .../ValueWrapperFormattingExtensions.cs | 167 ++++++++ ...verter.cs => ValueWrapperJsonConverter.cs} | 4 +- ...=> ValueWrapperNewtonsoftJsonConverter.cs} | 6 +- .../ValueWrapperParsingExtensions.cs | 159 +++++++ DomainModeling/DomainModeling.csproj | 19 +- 27 files changed, 1417 insertions(+), 315 deletions(-) rename DomainModeling.Generator/{IGeneratable.cs => GeneratableExtensions.cs} (67%) create mode 100644 DomainModeling.Generator/SymbolExtensions.cs create mode 100644 DomainModeling.Generator/ValueWrapperGenerator.cs create mode 100644 DomainModeling/Conversions/IFormattableWrapper.cs create mode 100644 DomainModeling/Conversions/IParsableWrapper.cs create mode 100644 DomainModeling/Conversions/IValueWrapper.cs create mode 100644 DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs rename DomainModeling/Conversions/{WrapperJsonConverter.cs => ValueWrapperJsonConverter.cs} (98%) rename DomainModeling/Conversions/{NewtonsoftWrapperJsonConverter.cs => ValueWrapperNewtonsoftJsonConverter.cs} (94%) create mode 100644 DomainModeling/Conversions/ValueWrapperParsingExtensions.cs diff --git a/DomainModeling.Example/DomainModeling.Example.csproj b/DomainModeling.Example/DomainModeling.Example.csproj index 806a8b1..664335d 100644 --- a/DomainModeling.Example/DomainModeling.Example.csproj +++ b/DomainModeling.Example/DomainModeling.Example.csproj @@ -1,8 +1,8 @@ - + Exe - net8.0 + net9.0 Architect.DomainModeling.Example Architect.DomainModeling.Example Enable diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs index 564222a..582e26d 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs @@ -5,7 +5,9 @@ namespace Architect.DomainModeling.Generator.Configurators; public partial class DomainModelConfiguratorGenerator { - internal static void GenerateSourceForIdentities(SourceProductionContext context, (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input) + internal static void GenerateSourceForIdentities( + SourceProductionContext context, + (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input) { context.CancellationToken.ThrowIfCancellationRequested(); @@ -16,7 +18,7 @@ internal static void GenerateSourceForIdentities(SourceProductionContext context 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()); + configurator.ConfigureIdentity<{generatable.ContainingNamespace}.{generatable.TypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator.Args()); """)); var source = $@" diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs index ca0a471..17b38e2 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs @@ -5,7 +5,9 @@ namespace Architect.DomainModeling.Generator.Configurators; public partial class DomainModelConfiguratorGenerator { - internal static void GenerateSourceForWrapperValueObjects(SourceProductionContext context, (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input) + internal static void GenerateSourceForWrapperValueObjects( + SourceProductionContext context, + (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input) { context.CancellationToken.ThrowIfCancellationRequested(); diff --git a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs index a096fab..d0ad05c 100644 --- a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs +++ b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs @@ -404,7 +404,7 @@ public override InstantiationBinding With(IReadOnlyList parame AddSource(context, source, "EntityFrameworkDomainModelConfigurationExtensions", $"{Constants.DomainModelingNamespace}.EntityFramework"); } - internal sealed record Generatable : IGeneratable + internal sealed record Generatable { public bool UsesEntityFrameworkConventions { get; set; } /// diff --git a/DomainModeling.Generator/DomainEventGenerator.cs b/DomainModeling.Generator/DomainEventGenerator.cs index 11ecd08..f0e9fff 100644 --- a/DomainModeling.Generator/DomainEventGenerator.cs +++ b/DomainModeling.Generator/DomainEventGenerator.cs @@ -39,6 +39,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella private static Generatable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var model = context.SemanticModel; var tds = (TypeDeclarationSyntax)context.Node; var type = model.GetDeclaredSymbol(tds); @@ -101,7 +103,7 @@ internal enum DomainEventTypeComponents : ulong DefaultConstructor = 1 << 1, } - internal sealed record Generatable : IGeneratable + internal sealed record Generatable { public bool IsDomainObject { get; set; } public string TypeName { get; set; } = null!; diff --git a/DomainModeling.Generator/DummyBuilderGenerator.cs b/DomainModeling.Generator/DummyBuilderGenerator.cs index 6e5b2e7..326dd5c 100644 --- a/DomainModeling.Generator/DummyBuilderGenerator.cs +++ b/DomainModeling.Generator/DummyBuilderGenerator.cs @@ -34,6 +34,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella private static Builder? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var model = context.SemanticModel; var tds = (TypeDeclarationSyntax)context.Node; var type = model.GetDeclaredSymbol((TypeDeclarationSyntax)context.Node); @@ -334,7 +336,7 @@ namespace {containingNamespace} return result; } - private sealed record Builder : IGeneratable + private sealed record Builder { public string TypeFullMetadataName { get; set; } = null!; public string ModelTypeFullMetadataName { get; set; } = null!; diff --git a/DomainModeling.Generator/EntityGenerator.cs b/DomainModeling.Generator/EntityGenerator.cs index a677e5f..6216ecc 100644 --- a/DomainModeling.Generator/EntityGenerator.cs +++ b/DomainModeling.Generator/EntityGenerator.cs @@ -39,6 +39,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella private static Generatable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var model = context.SemanticModel; var tds = (TypeDeclarationSyntax)context.Node; var type = model.GetDeclaredSymbol(tds); @@ -101,7 +103,7 @@ internal enum EntityTypeComponents : ulong DefaultConstructor = 1 << 1, } - internal sealed record Generatable : IGeneratable + internal sealed record Generatable { public bool IsEntity { get; set; } public string TypeName { get; set; } = null!; diff --git a/DomainModeling.Generator/IGeneratable.cs b/DomainModeling.Generator/GeneratableExtensions.cs similarity index 67% rename from DomainModeling.Generator/IGeneratable.cs rename to DomainModeling.Generator/GeneratableExtensions.cs index 680de1a..dcbcbb2 100644 --- a/DomainModeling.Generator/IGeneratable.cs +++ b/DomainModeling.Generator/GeneratableExtensions.cs @@ -1,20 +1,7 @@ using System.Runtime.CompilerServices; -using Microsoft.CodeAnalysis; namespace Architect.DomainModeling.Generator; -/// -/// -/// Interface intended for record types used to store the transformation data of source generators. -/// -/// -/// Extension methods on this type allow additional data (such as an ) to be associated, without that data becoming part of the record's equality implementation. -/// -/// -internal interface IGeneratable -{ -} - internal static class GeneratableExtensions { /// diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index ffecab3..f036c2c 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Architect.DomainModeling.Generator.Common; using Architect.DomainModeling.Generator.Configurators; using Microsoft.CodeAnalysis; @@ -6,22 +7,96 @@ namespace Architect.DomainModeling.Generator; -[Generator] public class IdentityGenerator : SourceGenerator { public override void Initialize(IncrementalGeneratorInitializationContext context) + { + // We are invoked from another source generator + // This lets us combine knowledge of various value wrapper kinds + } + + /// + /// Intializes a provider containing only the basic info of the wrapper type and underlying type. + /// This one should not change often, making it suitable for use with Collect(). + /// + internal void InitializeBasicProvider(IncrementalGeneratorInitializationContext context, out IncrementalValuesProvider provider) + { + provider = context.SyntaxProvider + .CreateSyntaxProvider( + FilterSyntaxNode, + (context, ct) => context.SemanticModel.GetDeclaredSymbol((TypeDeclarationSyntax)context.Node) switch + { + INamedTypeSymbol type when LooksLikeEntity(type) && IsEntity(type, out var entityInterface) && entityInterface.TypeArguments[0].TypeKind == TypeKind.Error && + entityInterface.TypeArguments[1] is ITypeSymbol underlyingType => + new ValueWrapperGenerator.BasicGeneratable( + typeName: entityInterface.TypeArguments[0].Name, + containingNamespace: type.ContainingNamespace.ToString(), + underlyingTypeFullyQualifiedName: underlyingType.ToString(), + isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), + isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }), + isUtf8SpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), + isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), + INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attribute.AttributeClass!.TypeArguments[0] is ITypeSymbol underlyingType => + new ValueWrapperGenerator.BasicGeneratable( + typeName: type.Name, + containingNamespace: type.ContainingNamespace.ToString(), + underlyingTypeFullyQualifiedName: underlyingType.ToString(), + isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), + isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }), + isUtf8SpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), + isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), + _ => default, + }) + .Where(generatable => generatable != default) + .DeduplicatePartials()!; + } + + /// + /// Takes general info of all identities, and of all nodes of all kinds of value wrappers (including identities). + /// Additionally gathers detailed info per individual identity. + /// Generates source based on all of the above. + /// + internal void Generate(IncrementalGeneratorInitializationContext context, + IncrementalValueProvider> identities, + IncrementalValueProvider> valueWrappers) { var provider = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, TransformSyntaxNode) .Where(generatable => generatable is not null) - .DeduplicatePartials(); + .DeduplicatePartials()!; + + context.RegisterSourceOutput(provider.Combine(valueWrappers), GenerateSource!); - context.RegisterSourceOutput(provider, GenerateSource!); + var aggregatedProvider = identities.Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); - var aggregatedProvider = provider - .Collect() - .Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForIdentities); + } - context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForIdentities!); + private static bool LooksLikeEntity(INamedTypeSymbol type) + { + var result = type.IsOrInheritsClass(baseType => baseType.Name == Constants.EntityTypeName, out _); + return result; + } + + private static bool IsEntity(INamedTypeSymbol type, out INamedTypeSymbol entityInterface) + { + var result = type.IsOrInheritsClass(baseType => baseType.Arity == 2 && baseType.IsType(Constants.EntityTypeName, Constants.DomainModelingNamespace), out entityInterface); + return result; + } + + private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeData attribute) + { + attribute = null!; + if (type.GetAttribute("IdentityValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) is AttributeData { AttributeClass: not null } attributeOutput) + attribute = attributeOutput; + return attribute != null; } private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) @@ -50,6 +125,8 @@ 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,17 +137,17 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; ITypeSymbol underlyingType; - var isBasedOnEntity = type.IsOrInheritsClass(baseType => baseType.Name == Constants.EntityTypeName, out _); + var isBasedOnEntity = LooksLikeEntity(type); // Path A: An Entity subclass that might be an Entity for which TId may have to be generated if (isBasedOnEntity) { // Only an actual Entity - if (!type.IsOrInheritsClass(baseType => baseType.Arity == 2 && baseType.IsType(Constants.EntityTypeName, Constants.DomainModelingNamespace), out var entityType)) + if (!IsEntity(type, out var entityInterface)) return null; - var idType = entityType.TypeArguments[0]; - underlyingType = entityType.TypeArguments[1]; + var idType = entityInterface.TypeArguments[0]; + underlyingType = entityInterface.TypeArguments[1]; result.EntityTypeName = type.Name; result.EntityTypeLocation = type.Locations.FirstOrDefault(); @@ -92,10 +169,10 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella else { // Only with the attribute - if (type.GetAttribute("IdentityValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) is not AttributeData { AttributeClass: not null } attribute) + if (!HasRequiredAttribute(type, out var attribute)) return null; - underlyingType = attribute.AttributeClass.TypeArguments[0]; + underlyingType = attribute.AttributeClass!.TypeArguments[0]; result.IdTypeExists = true; result.IdTypeLocation = type.Locations.FirstOrDefault(); @@ -147,55 +224,65 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // 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.Arity == 0 && method.Parameters.Length == 2 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_Equality") && 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.Arity == 0 && method.Parameters.Length == 2 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_Inequality") && 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.Arity == 0 && method.Parameters.Length == 2 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_GreaterThan") && 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.Arity == 0 && method.Parameters.Length == 2 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_LessThan") && 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.Arity == 0 && method.Parameters.Length == 2 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_GreaterThanOrEqual") && 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.Arity == 0 && method.Parameters.Length == 2 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_LessThanOrEqual") && 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.Arity == 0 && method.Parameters.Length == 1 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && + (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && 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.Arity == 0 && method.Parameters.Length == 1 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && + (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && 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.Arity == 0 && method.Parameters.Length == 1 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && + (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && 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.Arity == 0 && method.Parameters.Length == 1 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && + (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && (underlyingType.IsReferenceType ? method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default) : method.ReturnType.IsType(nameof(Nullable), "System") && method.ReturnType.HasSingleGenericTypeArgument(underlyingType)) && @@ -222,72 +309,88 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella 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 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + member.HasNameOrExplicitInterfaceImplementationName("TryParse") && 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 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("Parse") && 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 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && + member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && 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 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + member.HasNameOrExplicitInterfaceImplementationName("TryParse") && 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 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("Parse") && 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 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && + member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && 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 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + member.HasNameOrExplicitInterfaceImplementationName("TryParse") && 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 && + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("Parse") && method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); + existingComponents |= IdTypeComponents.CreateMethod.If(members.Any(member => + member is IMethodSymbol method && method.IsStatic && method.Arity == 0 && method.Parameters.Length == 1 && + member.HasNameOrExplicitInterfaceImplementationName("Create") && + method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); + result.ExistingComponents = existingComponents; } + 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)"); result.UnderlyingTypeFullyQualifiedName = underlyingType.ToString(); - result.UnderlyingTypeIsToStringNullable = underlyingType.IsToStringNullable(); + result.IsToStringNullable = underlyingType.IsToStringNullable() || result.ToStringExpression.Contains('?'); 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) + private static void GenerateSource(SourceProductionContext context, (Generatable Generatable, ImmutableArray ValueWrappers) input) { context.CancellationToken.ThrowIfCancellationRequested(); + var generatable = input.Generatable; + var valueWrappers = input.ValueWrappers; + var containingNamespace = generatable.ContainingNamespace; var idTypeName = generatable.IdTypeName; var underlyingTypeFullyQualifiedName = generatable.UnderlyingTypeFullyQualifiedName; @@ -296,7 +399,7 @@ private static void GenerateSource(SourceProductionContext context, Generatable var isRecord = generatable.IsRecord; var isINumber = generatable.UnderlyingTypeIsINumber; var isString = generatable.UnderlyingTypeIsString; - var isToStringNullable = generatable.UnderlyingTypeIsToStringNullable; + var isToStringNullable = generatable.IsToStringNullable; var toStringExpression = generatable.ToStringExpression; var hashCodeExpression = generatable.HashCodeExpression; var equalityExpression = generatable.EqualityExpression; @@ -306,6 +409,10 @@ private static void GenerateSource(SourceProductionContext context, Generatable var existingComponents = generatable.ExistingComponents; var hasIdentityValueObjectAttribute = generatable.IdTypeExists; + (var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( + valueWrappers, + typeName: idTypeName, containingNamespace: containingNamespace, underlyingTypeFullyQualifiedName: underlyingTypeFullyQualifiedName); + if (generatable.IdTypeExists) { // Entity was needlessly used, with a preexisting TId @@ -387,6 +494,10 @@ private static void GenerateSource(SourceProductionContext context, Generatable // JavaScript (and arguably, by extent, JSON) have insufficient numeric capacity to properly hold the longer numeric types var underlyingTypeIsNumericUnsuitableForJson = generatable.UnderlyingTypeIsNumericUnsuitableForJson; + var formattableParsableWrapperSuffix = generatable.UnderlyingTypeIsString + ? $"StringWrapper<{idTypeName}>" + : $"Wrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>"; + var source = $@" using System; using System.Collections.Generic; @@ -409,14 +520,15 @@ namespace {containingNamespace} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} {(hasIdentityValueObjectAttribute ? "" : $"[IdentityValueObject<{underlyingTypeFullyQualifiedName}>]")} - {(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} - : {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, + {(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} : + {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, + IValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}>, - ISpanFormattable, - ISpanParsable<{idTypeName}>, - IUtf8SpanFormattable, - IUtf8SpanParsable<{idTypeName}>, + {(isSpanFormattable ? "" : "//")}ISpanFormattable, ISpanFormattable{formattableParsableWrapperSuffix}, + {(isSpanParsable ? "" : "//")}ISpanParsable<{idTypeName}>, ISpanParsable{formattableParsableWrapperSuffix}, + {(isUtf8SpanFormattable ? "" : "//")}IUtf8SpanFormattable, IUtf8SpanFormattable{formattableParsableWrapperSuffix}, + {(isUtf8SpanParsable ? "" : "//")}IUtf8SpanParsable<{idTypeName}>, IUtf8SpanParsable{formattableParsableWrapperSuffix}, {Constants.SerializableDomainObjectInterfaceTypeName}<{idTypeName}, {underlyingTypeFullyQualifiedName}> {{ {(existingComponents.HasFlags(IdTypeComponents.Value) ? "/*" : "")} @@ -432,6 +544,35 @@ namespace {containingNamespace} }} {(existingComponents.HasFlags(IdTypeComponents.Constructor) ? "*/" : "")} + {(existingComponents.HasFlags(IdTypeComponents.CreateMethod) ? "/*" : "")} + static {idTypeName} IValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Create({underlyingTypeFullyQualifiedName} value) + {{ + return new {idTypeName}(value); + }} + {(existingComponents.HasFlags(IdTypeComponents.CreateMethod) ? "*/" : "")} + + {(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) ? "/*" : "")} + /// + /// Deserializes a plain value back into a domain object, without using a parameterized constructor. + /// + 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 }}; + }} + {(existingComponents.HasFlags(IdTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} + {(existingComponents.HasFlags(IdTypeComponents.StringComparison) ? "/*" : "")} {(isString ? @"private StringComparison StringComparison => StringComparison.Ordinal;" @@ -478,28 +619,6 @@ public int CompareTo({idTypeName} other) }} {(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) ? "/*" : "")} - /// - /// 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 }}; - }} - {(existingComponents.HasFlags(IdTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "/*" : "")} public static bool operator ==({idTypeName} left, {idTypeName} right) => left.Equals(right); {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "*/" : "")} @@ -540,56 +659,60 @@ public int CompareTo({idTypeName} other) #region Formatting & Parsing - {(existingComponents.HasFlags(IdTypeComponents.FormattableToStringOverride) ? "/*" : "")} +//#if !NET10_0_OR_GREATER // Starting with .NET 10, these operations are provided by default implementations and extension methods + + {(!isSpanFormattable || existingComponents.HasFlags(IdTypeComponents.FormattableToStringOverride) ? "/*" : "")} public string ToString(string? format, IFormatProvider? formatProvider) => FormattingHelper.ToString(this.Value, format, formatProvider); - {(existingComponents.HasFlags(IdTypeComponents.FormattableToStringOverride) ? "*/" : "")} + {(!isSpanFormattable || existingComponents.HasFlags(IdTypeComponents.FormattableToStringOverride) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.SpanFormattableTryFormatMethod) ? "/*" : "")} + {(!isSpanFormattable || 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) ? "*/" : "")} + {(!isSpanFormattable || existingComponents.HasFlags(IdTypeComponents.SpanFormattableTryFormatMethod) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.ParsableTryParseMethod) ? "/*" : "")} + {(!isSpanParsable || 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) ? "*/" : "")} + {(!isSpanParsable || existingComponents.HasFlags(IdTypeComponents.ParsableTryParseMethod) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.SpanParsableTryParseMethod) ? "/*" : "")} + {(!isSpanParsable || 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) ? "*/" : "")} + {(!isSpanParsable || existingComponents.HasFlags(IdTypeComponents.SpanParsableTryParseMethod) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.ParsableParseMethod) ? "/*" : "")} + {(!isSpanParsable || existingComponents.HasFlags(IdTypeComponents.ParsableParseMethod) ? "/*" : "")} public static {idTypeName} Parse(string s, IFormatProvider? provider) => ({idTypeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(s, provider); - {(existingComponents.HasFlags(IdTypeComponents.ParsableParseMethod) ? "*/" : "")} + {(!isSpanParsable || existingComponents.HasFlags(IdTypeComponents.ParsableParseMethod) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.SpanParsableParseMethod) ? "/*" : "")} + {(!isSpanParsable || existingComponents.HasFlags(IdTypeComponents.SpanParsableParseMethod) ? "/*" : "")} public static {idTypeName} Parse(ReadOnlySpan s, IFormatProvider? provider) => ({idTypeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(s, provider); - {(existingComponents.HasFlags(IdTypeComponents.SpanParsableParseMethod) ? "*/" : "")} + {(!isSpanParsable || existingComponents.HasFlags(IdTypeComponents.SpanParsableParseMethod) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanFormattableTryFormatMethod) ? "/*" : "")} + {(!isUtf8SpanFormattable || 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) ? "*/" : "")} + {(!isUtf8SpanFormattable || existingComponents.HasFlags(IdTypeComponents.Utf8SpanFormattableTryFormatMethod) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableTryParseMethod) ? "/*" : "")} + {(!isUtf8SpanParsable || 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) ? "*/" : "")} + {(!isUtf8SpanParsable || existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableTryParseMethod) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableParseMethod) ? "/*" : "")} + {(!isUtf8SpanParsable || existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableParseMethod) ? "/*" : "")} public static {idTypeName} Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => ({idTypeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(utf8Text, provider); - {(existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableParseMethod) ? "*/" : "")} + {(!isUtf8SpanParsable || existingComponents.HasFlags(IdTypeComponents.Utf8SpanParsableParseMethod) ? "*/" : "")} + +//#endif #endregion }} @@ -627,7 +750,6 @@ internal enum IdTypeComponents : ulong SerializeToUnderlying = 1UL << 20, DeserializeFromUnderlying = 1UL << 21, UnsettableValue = 1UL << 22, - FormattableToStringOverride = 1UL << 24, ParsableTryParseMethod = 1UL << 25, ParsableParseMethod = 1UL << 26, @@ -637,9 +759,10 @@ internal enum IdTypeComponents : ulong Utf8SpanFormattableTryFormatMethod = 1UL << 30, Utf8SpanParsableTryParseMethod = 1UL << 31, Utf8SpanParsableParseMethod = 1UL << 32, + CreateMethod = 1UL << 33, } - internal sealed record Generatable : IGeneratable + private sealed record Generatable { private uint _bits; public bool IdTypeExists { get => this._bits.GetBit(0); set => this._bits.SetBit(0, value); } @@ -653,8 +776,12 @@ internal sealed record Generatable : IGeneratable 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 ToStringExpression { get; set; } = null!; + public string HashCodeExpression { get; set; } = null!; + public string EqualityExpression { get; set; } = null!; + public string ComparisonExpression { 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 IsToStringNullable { 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); } @@ -663,10 +790,6 @@ internal sealed record Generatable : IGeneratable public bool IsSerializableDomainObject { get => this._bits.GetBit(14); set => this._bits.SetBit(14, value); } public Accessibility Accessibility { 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 index fcea385..643d033 100644 --- a/DomainModeling.Generator/JsonSerializationGenerator.cs +++ b/DomainModeling.Generator/JsonSerializationGenerator.cs @@ -8,12 +8,12 @@ internal static class JsonSerializationGenerator public static string WriteJsonConverterAttribute(string modelTypeName, string underlyingTypeFullyQualifiedName, bool numericAsString = false) { - return $"[System.Text.Json.Serialization.JsonConverter(typeof({(numericAsString ? "LargeNumber" : "")}WrapperJsonConverter<{modelTypeName}, {underlyingTypeFullyQualifiedName}>))]"; + return $"[System.Text.Json.Serialization.JsonConverter(typeof({(numericAsString ? "LargeNumber" : "")}ValueWrapperJsonConverter<{modelTypeName}, {underlyingTypeFullyQualifiedName}>))]"; } public static string WriteNewtonsoftJsonConverterAttribute(string modelTypeName, string underlyingTypeFullyQualifiedName, bool numericAsString = false) { - return $"[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft{(numericAsString ? "LargeNumber" : "")}WrapperJsonConverter<{modelTypeName}, {underlyingTypeFullyQualifiedName}>))]"; + return $"[Newtonsoft.Json.JsonConverter(typeof({(numericAsString ? "LargeNumber" : "")}ValueWrapperNewtonsoftJsonConverter<{modelTypeName}, {underlyingTypeFullyQualifiedName}>))]"; } } diff --git a/DomainModeling.Generator/SymbolExtensions.cs b/DomainModeling.Generator/SymbolExtensions.cs new file mode 100644 index 0000000..6c571b4 --- /dev/null +++ b/DomainModeling.Generator/SymbolExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.CodeAnalysis; + +namespace Architect.DomainModeling.Generator; + +internal static class SymbolExtensions +{ + /// + /// Returns whether the given (such as a method) has the given , either straight up or via explicit interface implementation. + /// The latter requires specialized matching, which this method approximates. + /// + public static bool HasNameOrExplicitInterfaceImplementationName(this ISymbol symbol, string name) + { + var needle = name.AsSpan(); + var haystack = symbol.Name.AsSpan(); + + var index = haystack.LastIndexOf(needle); + + if (index < 0) + return false; + + if (index == 0) + return true; + + var nameFollowsDot = haystack[index - 1] == '.'; + + return nameFollowsDot; + } +} diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index a8ad6bb..029fba1 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -31,6 +31,8 @@ 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; @@ -420,7 +422,7 @@ private enum ValueObjectTypeComponents : ulong DefaultConstructor = 1 << 14, } - private sealed record Generatable : IGeneratable + private sealed record Generatable { public bool IsValueObject { get; set; } public bool IsPartial { get; set; } diff --git a/DomainModeling.Generator/ValueWrapperGenerator.cs b/DomainModeling.Generator/ValueWrapperGenerator.cs new file mode 100644 index 0000000..bedb089 --- /dev/null +++ b/DomainModeling.Generator/ValueWrapperGenerator.cs @@ -0,0 +1,104 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Architect.DomainModeling.Generator; + +/// +/// Combined source generator that delegates to the various concrete generators of value wrappers, so that they may have knowledge of each other. +/// +[Generator] +public class ValueWrapperGenerator : IIncrementalGenerator +{ + private IdentityGenerator IdentityGenerator { get; } = new IdentityGenerator(); + private WrapperValueObjectGenerator WrapperValueObjectGenerator { get; } = new WrapperValueObjectGenerator(); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + this.IdentityGenerator.InitializeBasicProvider(context, out var identityProvider); + this.WrapperValueObjectGenerator.InitializeBasicProvider(context, out var wrapperValueObjectProvider); + + var identities = identityProvider.Collect(); + var wrapperValueObjects = wrapperValueObjectProvider.Collect(); + + var valueWrappers = identities.Combine(wrapperValueObjects) + .Select((tuple, ct) => tuple.Left.AddRange(tuple.Right)); + + this.IdentityGenerator.Generate(context, identities, valueWrappers); + this.WrapperValueObjectGenerator.Generate(context, wrapperValueObjects, valueWrappers); + } + + /// + /// Utility method that recursively determines which formatting and parsing interfaces are supported, based on all known value wrappers. + /// This allows even nested value wrappers to dig down into the deepest underlying type. + /// + internal static (bool isSpanFormattable, bool isSpanParsable, bool isUtf8SpanFormattable, bool isUtf8SpanParsable) GetFormattabilityAndParsabilityRecursively( + ImmutableArray valueWrapperGeneratables, + string typeName, string containingNamespace, string underlyingTypeFullyQualifiedName) + { + var isSpanFormattable = false; + var isSpanParsable = false; + var isUtf8SpanFormattable = false; + var isUtf8SpanParsable = false; + + // Concatenate our namespace and name, so that we are comparable to further iterations + Span ownFullyQualifiedTypeName = stackalloc char[containingNamespace.Length + 1 + typeName.Length]; + ownFullyQualifiedTypeName = [.. containingNamespace, '.', .. typeName]; + + // A generated type will honor the formattability/parsability of its underlying type + // As such, for generated types, it is worth recursing into the underlying types to discover if formattability/parsability is available through the chain + var nextTypeName = (ReadOnlySpan)ownFullyQualifiedTypeName; + bool hasUnderlyingGeneratedType; + do + { + hasUnderlyingGeneratedType = false; + foreach (ref readonly var item in valueWrapperGeneratables.AsSpan()) + { + // Based on the fully qualified type name we are looking for, try to find the corresponding NameOnlyGeneratable + if (nextTypeName.EndsWith(item.TypeName.AsSpan()) && + nextTypeName.StartsWith(item.ContainingNamespace.AsSpan()) && + item.ContainingNamespace.Length + 1 + item.TypeName.Length == nextTypeName.Length && + nextTypeName[item.ContainingNamespace.Length] == '.') + { + hasUnderlyingGeneratedType = true; + nextTypeName = item.UnderlyingTypeFullyQualifiedName.AsSpan(); + isSpanFormattable |= item.IsSpanFormattable; + isSpanParsable |= item.IsSpanParsable; + isUtf8SpanFormattable |= item.IsUtf8SpanFormattable; + isUtf8SpanParsable |= item.IsUtf8SpanParsable; + break; + } + } + } while (hasUnderlyingGeneratedType && (isSpanFormattable & isSpanParsable & isUtf8SpanFormattable & isUtf8SpanParsable) == false); // Possible & worth seeking deeper + + return (isSpanFormattable, isSpanParsable, isUtf8SpanFormattable, isUtf8SpanParsable); + } + + internal readonly record struct BasicGeneratable + { + public string TypeName { get; } + public string ContainingNamespace { get; } + public string UnderlyingTypeFullyQualifiedName { get; } + public bool IsSpanFormattable { get; } + public bool IsSpanParsable { get; } + public bool IsUtf8SpanFormattable { get; } + public bool IsUtf8SpanParsable { get; } + + public BasicGeneratable( + string typeName, + string containingNamespace, + string underlyingTypeFullyQualifiedName, + bool isSpanFormattable, + bool isSpanParsable, + bool isUtf8SpanFormattable, + bool isUtf8SpanParsable) + { + this.TypeName = typeName; + this.ContainingNamespace = containingNamespace; + this.UnderlyingTypeFullyQualifiedName = underlyingTypeFullyQualifiedName; + this.IsSpanFormattable = isSpanFormattable; + this.IsSpanParsable = isSpanParsable; + this.IsUtf8SpanFormattable = isUtf8SpanFormattable; + this.IsUtf8SpanParsable = isUtf8SpanParsable; + } + } +} diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index d0cf599..ec4c622 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Architect.DomainModeling.Generator.Common; using Architect.DomainModeling.Generator.Configurators; using Microsoft.CodeAnalysis; @@ -6,22 +7,70 @@ namespace Architect.DomainModeling.Generator; -[Generator] public class WrapperValueObjectGenerator : SourceGenerator { public override void Initialize(IncrementalGeneratorInitializationContext context) + { + // We are invoked from another source generator + // This lets us combine knowledge of various value wrapper kinds + } + + /// + /// Intializes a provider containing only the basic info of the wrapper type and underlying type. + /// This one should not change often, making it suitable for use with Collect(). + /// + internal void InitializeBasicProvider(IncrementalGeneratorInitializationContext context, out IncrementalValuesProvider provider) + { + provider = context.SyntaxProvider + .CreateSyntaxProvider( + FilterSyntaxNode, + (context, ct) => context.SemanticModel.GetDeclaredSymbol((TypeDeclarationSyntax)context.Node) switch + { + INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attribute.AttributeClass!.TypeArguments[0] is ITypeSymbol underlyingType => + new ValueWrapperGenerator.BasicGeneratable( + typeName: type.Name, + containingNamespace: type.ContainingNamespace.ToString(), + underlyingTypeFullyQualifiedName: underlyingType.ToString() is string underlyingTypeName ? underlyingTypeName : (underlyingTypeName = null!), + isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), + isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }), + isUtf8SpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), + isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), + _ => default, + }) + .Where(generatable => generatable != default) + .DeduplicatePartials()!; + } + + /// + /// Takes general info of all wrapper value objects, and of all nodes of all kinds of value wrappers (including wrapper value objects). + /// Additionally gathers detailed info per individual wrapper value object. + /// Generates source based on all of the above. + /// + internal void Generate(IncrementalGeneratorInitializationContext context, + IncrementalValueProvider> wrapperValueObjects, + IncrementalValueProvider> valueWrappers) { var provider = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, TransformSyntaxNode) .Where(generatable => generatable is not null) .DeduplicatePartials(); - context.RegisterSourceOutput(provider, GenerateSource!); + context.RegisterSourceOutput(provider.Combine(valueWrappers), GenerateSource!); - var aggregatedProvider = provider - .Collect() - .Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + var aggregatedProvider = wrapperValueObjects.Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); - context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForWrapperValueObjects!); + context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForWrapperValueObjects); + } + + private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeData attribute) + { + attribute = null!; + if (type.GetAttribute("WrapperValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) is AttributeData { AttributeClass: not null } attributeOutput) + attribute = attributeOutput; + return attribute != null; } private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) @@ -39,6 +88,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella private static Generatable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var model = context.SemanticModel; var tds = (TypeDeclarationSyntax)context.Node; var type = model.GetDeclaredSymbol(tds); @@ -47,10 +98,10 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; // Only with the attribute - if (type.GetAttribute("WrapperValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) is not AttributeData { AttributeClass: not null } attribute) + if (!HasRequiredAttribute(type, out var attribute)) return null; - var underlyingType = attribute.AttributeClass.TypeArguments[0]; + var underlyingType = attribute.AttributeClass!.TypeArguments[0]; var result = new Generatable(); result.TypeLocation = type.Locations.FirstOrDefault(); @@ -67,12 +118,15 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella result.TypeName = type.Name; // Will be non-generic if we pass the conditions to proceed with generation result.ContainingNamespace = type.ContainingNamespace.ToString(); + 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.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"; @@ -120,55 +174,65 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // 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 && + member is IMethodSymbol method && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_Equality") && 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 && + member is IMethodSymbol method && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_Inequality") && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.GreaterThanOperator.If(members.Any(member => - member.Name == "op_GreaterThan" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member is IMethodSymbol method && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_GreaterThan") && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.LessThanOperator.If(members.Any(member => - member.Name == "op_LessThan" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member is IMethodSymbol method && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_LessThan") && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.GreaterEqualsOperator.If(members.Any(member => - member.Name == "op_GreaterThanOrEqual" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member is IMethodSymbol method && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_GreaterThanOrEqual") && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.LessEqualsOperator.If(members.Any(member => - member.Name == "op_LessThanOrEqual" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member is IMethodSymbol method && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("op_LessThanOrEqual") && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.ConvertToOperator.If(members.Any(member => - (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && + member is IMethodSymbol method && method.Parameters.Length == 1 && + (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && method.ReturnType.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.ConvertFromOperator.If(members.Any(member => - (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && + member is IMethodSymbol method && method.Parameters.Length == 1 && + (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default) && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); // Consider having a reference-typed underlying type as already having the operator (though actually it does not apply at all) existingComponents |= WrapperValueObjectTypeComponents.NullableConvertToOperator.If(!underlyingType.IsValueType || members.Any(member => - (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && + member is IMethodSymbol method && method.Parameters.Length == 1 && + (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && method.ReturnType.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[0].Type.IsType(nameof(Nullable), "System") && method.Parameters[0].Type.HasSingleGenericTypeArgument(underlyingType))); // Consider having a reference-typed underlying type as already having the operator (though actually it does not apply at all) existingComponents |= WrapperValueObjectTypeComponents.NullableConvertFromOperator.If(!underlyingType.IsValueType || members.Any(member => - (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && + member is IMethodSymbol method && method.Parameters.Length == 1 && + (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && method.ReturnType.IsType(nameof(Nullable), "System") && method.ReturnType.HasSingleGenericTypeArgument(underlyingType) && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); @@ -190,68 +254,97 @@ 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)))); + 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 is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + member.HasNameOrExplicitInterfaceImplementationName("TryParse") && + 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 is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("Parse") && + method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType())); + + existingComponents |= WrapperValueObjectTypeComponents.SpanFormattableTryFormatMethod.If( + members.Any(member => + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && + member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && + 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 is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + member.HasNameOrExplicitInterfaceImplementationName("TryParse") && + 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 is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("Parse") && + method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); + + existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanFormattableTryFormatMethod.If( + members.Any(member => + member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && + member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && + 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 is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + member.HasNameOrExplicitInterfaceImplementationName("TryParse") && + 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 is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("Parse") && + method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && + method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); + + existingComponents |= WrapperValueObjectTypeComponents.CreateMethod.If(members.Any(member => + member is IMethodSymbol method && method.IsStatic && method.Arity == 0 && method.Parameters.Length == 1 && + member.HasNameOrExplicitInterfaceImplementationName("Create") && + method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); 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(); + result.IsToStringNullable = underlyingType.IsToStringNullable() || result.ToStringExpression.Contains('?'); + if (result.UnderlyingTypeIsString && result.ValueMemberLocation is not null) // Special-case string wrappers with a hand-written Value member + result.IsToStringNullable = !members.Any(member => + member.Name == "Value" && member is IPropertySymbol { GetMethod.ReturnType: { SpecialType: SpecialType.System_String, NullableAnnotation: NullableAnnotation.NotAnnotated, } }); return result; } - private static void GenerateSource(SourceProductionContext context, Generatable generatable) + private static void GenerateSource(SourceProductionContext context, (Generatable Generatable, ImmutableArray ValueWrappers) input) { context.CancellationToken.ThrowIfCancellationRequested(); + var generatable = input.Generatable; + var valueWrappers = input.ValueWrappers; + + (var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( + valueWrappers, + typeName: generatable.TypeName, containingNamespace: generatable.ContainingNamespace, underlyingTypeFullyQualifiedName: generatable.UnderlyingTypeFullyQualifiedName); + // Require the expected inheritance if (!generatable.IsPartial && !generatable.IsWrapperValueObject) { @@ -319,6 +412,10 @@ private static void GenerateSource(SourceProductionContext context, Generatable var isComparable = generatable.IsComparable; var existingComponents = generatable.ExistingComponents; + var formattableParsableWrapperSuffix = generatable.UnderlyingTypeIsString + ? $"StringWrapper<{typeName}>" + : $"Wrapper<{typeName}, {underlyingTypeFullyQualifiedName}>"; + // Warn if Value is not settable if (existingComponents.HasFlag(WrapperValueObjectTypeComponents.UnsettableValue)) context.ReportDiagnostic("WrapperValueObjectGeneratorUnsettableValue", "WrapperValueObject has Value property without init", @@ -344,14 +441,15 @@ namespace {containingNamespace} {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} - /* Generated */ {generatable.Accessibility.ToCodeString()} sealed partial{(generatable.IsRecord ? " record" : "")} class {typeName} - : {Constants.WrapperValueObjectTypeName}<{underlyingTypeFullyQualifiedName}>, + /* Generated */ {generatable.Accessibility.ToCodeString()} sealed partial{(generatable.IsRecord ? " record" : "")} class {typeName} : + {Constants.WrapperValueObjectTypeName}<{underlyingTypeFullyQualifiedName}>, + IValueWrapper<{typeName}, {underlyingTypeFullyQualifiedName}>, IEquatable<{typeName}>, - {(isComparable ? "" : "/*")}IComparable<{typeName}>,{(isComparable ? "" : "*/")} - ISpanFormattable, - ISpanParsable<{typeName}>, - IUtf8SpanFormattable, - IUtf8SpanParsable<{typeName}>, + {(isComparable ? "" : "//")}IComparable<{typeName}>, + {(isSpanFormattable ? "" : "//")}ISpanFormattable, ISpanFormattable{formattableParsableWrapperSuffix}, + {(isSpanParsable ? "" : "//")}ISpanParsable<{typeName}>, ISpanParsable{formattableParsableWrapperSuffix}, + {(isUtf8SpanFormattable ? "" : "//")}IUtf8SpanFormattable, IUtf8SpanFormattable{formattableParsableWrapperSuffix}, + {(isUtf8SpanParsable ? "" : "//")}IUtf8SpanParsable<{typeName}>, IUtf8SpanParsable{formattableParsableWrapperSuffix}, {Constants.SerializableDomainObjectInterfaceTypeName}<{typeName}, {underlyingTypeFullyQualifiedName}> {{ {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.StringComparison) ? "/*" : "")} @@ -378,8 +476,44 @@ namespace {containingNamespace} #pragma warning restore CS8618 {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DefaultConstructor) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CreateMethod) ? "/*" : "")} + static {typeName} IValueWrapper<{typeName}, {underlyingTypeFullyQualifiedName}>.Create({underlyingTypeFullyQualifiedName} value) + {{ + return new {typeName}(value); + }} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CreateMethod) ? "*/" : "")} + + {(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) ? $@" + [System.Runtime.CompilerServices.UnsafeAccessor(System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = ""{valueFieldName}"")] + private static extern ref {underlyingTypeFullyQualifiedName} GetValueFieldReference({typeName} instance);" : "")} + + /// + /// Deserializes a plain value back into a domain object, without using a parameterized constructor. + /// + 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) + var result = new {typeName}(); GetValueFieldReference(result) = value; return result;" : "")} +#pragma warning disable CS0618 // Obsolete constructor is intended for us + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.UnsettableValue) ? "//" : "")}return new {typeName}() {{ Value = value }}; +#pragma warning restore CS0618 + }} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ToStringOverride) ? "/*" : "")} - public sealed override string{(generatable.UnderlyingTypeHasNullableToString ? "?" : "")} ToString() + public sealed override string{(generatable.IsToStringNullable ? "?" : "")} ToString() {{ {(generatable.ToStringExpression.Contains('?') ? "// Null-safety protects instances produced by GetUninitializedObject()" : "")} return {generatable.ToStringExpression}; @@ -412,45 +546,14 @@ public bool Equals({typeName}? other) }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsMethod) ? " */" : "")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) ? "/*" : "")} - {(isComparable ? "" : "/*")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) || !isComparable ? "/*" : "")} public int CompareTo({typeName}? other) {{ return other is null ? +1 : {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) ? $@" - [System.Runtime.CompilerServices.UnsafeAccessor(System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = ""{valueFieldName}"")] - private static extern ref {underlyingTypeFullyQualifiedName} GetValueFieldReference({typeName} instance);" : "")} - - /// - /// 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) - var result = new {typeName}(); GetValueFieldReference(result) = value; return result;" : "")} -#pragma warning disable CS0618 // Obsolete constructor is intended for us - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.UnsettableValue) ? "//" : "")}return new {typeName}() {{ Value = value }}; -#pragma warning restore CS0618 - }} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) || !isComparable ? "*/" : "")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOperator) ? "/*" : "")} public static bool operator ==({typeName}? left, {typeName}? right) => left is null ? right is null : left.Equals(right); @@ -496,56 +599,60 @@ public int CompareTo({typeName}? other) #region Formatting & Parsing - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.FormattableToStringOverride) ? "/*" : "")} +//#if !NET10_0_OR_GREATER // Starting with .NET 10, these operations are provided by default implementations and extension methods + + {(!isSpanFormattable || 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) ? "/*" : "")} + {(!isSpanFormattable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.FormattableToStringOverride) ? "*/" : "")} + + {(!isSpanFormattable || 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) ? "/*" : "")} + {(!isSpanFormattable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanFormattableTryFormatMethod) ? "*/" : "")} + + {(!isSpanParsable || 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) ? "/*" : "")} + {(!isSpanFormattable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ParsableTryParseMethod) ? "*/" : "")} + + {(!isSpanParsable || 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) ? "/*" : "")} + {(!isSpanParsable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanParsableTryParseMethod) ? "*/" : "")} + + {(!isSpanParsable || 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) ? "/*" : "")} + {(!isSpanParsable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ParsableParseMethod) ? "*/" : "")} + + {(!isSpanParsable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanParsableParseMethod) ? "/*" : "")} public static {typeName} Parse(ReadOnlySpan s, IFormatProvider? provider) => ({typeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(s, provider); - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanParsableParseMethod) ? "*/" : "")} - - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanFormattableTryFormatMethod) ? "/*" : "")} + {(!isSpanParsable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.SpanParsableParseMethod) ? "*/" : "")} + + {(!isUtf8SpanFormattable || 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) ? "/*" : "")} + {(!isUtf8SpanFormattable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanFormattableTryFormatMethod) ? "*/" : "")} + + {(!isUtf8SpanParsable || 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) ? "/*" : "")} + {(!isUtf8SpanParsable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanParsableTryParseMethod) ? "*/" : "")} + + {(!isUtf8SpanParsable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanParsableParseMethod) ? "/*" : "")} public static {typeName} Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => ({typeName})ParsingHelper.Parse<{underlyingTypeFullyQualifiedName}>(utf8Text, provider); - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanParsableParseMethod) ? "*/" : "")} + {(!isUtf8SpanParsable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.Utf8SpanParsableParseMethod) ? "*/" : "")} + +//#endif #endregion }} @@ -593,9 +700,10 @@ internal enum WrapperValueObjectTypeComponents : ulong Utf8SpanFormattableTryFormatMethod = 1UL << 30, Utf8SpanParsableTryParseMethod = 1UL << 31, Utf8SpanParsableParseMethod = 1UL << 32, + CreateMethod = 1UL << 33, } - internal sealed record Generatable : IGeneratable + private sealed record Generatable { private uint _bits; public bool IsWrapperValueObject { get => this._bits.GetBit(0); set => this._bits.SetBit(0, value); } @@ -614,7 +722,8 @@ internal sealed record Generatable : IGeneratable 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 bool IsToStringNullable { get => this._bits.GetBit(12); set => this._bits.SetBit(12, value); } + public bool UnderlyingTypeIsInterface { get => this._bits.GetBit(13); set => this._bits.SetBit(13, value); } public string ValueFieldName { get; set; } = null!; public Accessibility Accessibility { get; set; } public WrapperValueObjectTypeComponents ExistingComponents { get; set; } diff --git a/DomainModeling.Tests/DomainModeling.Tests.csproj b/DomainModeling.Tests/DomainModeling.Tests.csproj index fd1abcb..1557932 100644 --- a/DomainModeling.Tests/DomainModeling.Tests.csproj +++ b/DomainModeling.Tests/DomainModeling.Tests.csproj @@ -1,11 +1,12 @@ - net9.0 + net9.0;net8.0 Architect.DomainModeling.Tests Architect.DomainModeling.Tests Enable Enable + 13 False diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index 8de5fe7..c7d5185 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using Architect.DomainModeling.Conversions; using Architect.DomainModeling.Tests.IdentityTestTypes; +using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; using Xunit; namespace Architect.DomainModeling.Tests @@ -537,7 +538,8 @@ 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.Equal("", ((FormatAndParseTestingIntId)RuntimeHelpers.GetUninitializedObject(typeof(FormatAndParseTestingIntId))).ToString(format: null, formatProvider: null)); + // Cannot be helped - see comments in IFormattableWrapper + Assert.Null(((FormatAndParseTestingIntId)RuntimeHelpers.GetUninitializedObject(typeof(FormatAndParseTestingIntId))).ToString(format: null, formatProvider: null)); } [Fact] @@ -561,6 +563,7 @@ public void SpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResult() Assert.Equal(1, charsWritten); Assert.Equal("5".AsSpan(), result); + // We succeeded at doing all we must - false is only for insufficient space Assert.True(((FormatAndParseTestingIntId)RuntimeHelpers.GetUninitializedObject(typeof(FormatAndParseTestingIntId))).TryFormat(result, out charsWritten, format: null, provider: null)); Assert.Equal(0, charsWritten); } @@ -586,6 +589,7 @@ public void UtfSpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResul Assert.Equal(1, bytesWritten); Assert.Equal("5"u8, result); + // We succeeded at doing all we must - false is only for insufficient space Assert.True(((FormatAndParseTestingIntId)RuntimeHelpers.GetUninitializedObject(typeof(FormatAndParseTestingIntId))).TryFormat(result, out bytesWritten, format: null, provider: null)); Assert.Equal(0, bytesWritten); } @@ -655,6 +659,16 @@ public void Utf8SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpected Assert.Equal(5, result4.Value?.Value.Value); Assert.Equal(result4, FormatAndParseTestingIntId.Parse(input, provider: null)); } + + [Fact] + public void ParsabilityAndFormattability_InAllScenarios_ShouldBeGeneratedAccordingToTransitiveAvailability() + { + var interfaces = typeof(FormatAndParseTestingUriWrapperId).GetInterfaces(); + Assert.Contains(interfaces, interf => interf.Name == "ISpanFormattable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "ISpanParsable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanFormattable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanParsable"); + } } // Use a namespace, since our source generators dislike nested types @@ -672,6 +686,9 @@ internal partial record struct DecimalId; [IdentityValueObject] internal partial record struct StringId; + [IdentityValueObject] + internal partial record struct WrapperId; + [IdentityValueObject] internal partial struct IgnoreCaseStringId { @@ -694,6 +711,10 @@ public FormatAndParseTestingIntWrapper(int value) this.Value = new IntId(value); } } + [IdentityValueObject] + internal partial struct FormatAndParseTestingUriWrapperId + { + } [IdentityValueObject] internal readonly partial struct JsonTestingIntId @@ -748,8 +769,8 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly /// Should merely compile. /// [IdentityValueObject] - [System.Text.Json.Serialization.JsonConverter(typeof(WrapperJsonConverter))] - [Newtonsoft.Json.JsonConverter(typeof(NewtonsoftWrapperJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(ValueWrapperJsonConverter))] + [Newtonsoft.Json.JsonConverter(typeof(ValueWrapperNewtonsoftJsonConverter))] internal readonly partial struct FullySelfImplementedIdentity : IIdentity, IEquatable, @@ -767,6 +788,27 @@ public FullySelfImplementedIdentity(int value) this.Value = value; } + public static FullySelfImplementedIdentity Create(int value) + { + return new FullySelfImplementedIdentity(value); + } + + /// + /// Serializes a domain object as a plain value. + /// + int ISerializableDomainObject.Serialize() + { + return this.Value; + } + + /// + /// Deserializes a plain value back into a domain object, without using a parameterized constructor. + /// + static FullySelfImplementedIdentity ISerializableDomainObject.Deserialize(int value) + { + return new FullySelfImplementedIdentity() { Value = value }; + } + public override int GetHashCode() { return this.Value.GetHashCode(); @@ -792,22 +834,6 @@ 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); @@ -826,6 +852,8 @@ static FullySelfImplementedIdentity ISerializableDomainObject FormattingHelper.ToString(this.Value, format, formatProvider); @@ -859,6 +887,8 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid public static FullySelfImplementedIdentity Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => (FullySelfImplementedIdentity)ParsingHelper.Parse(utf8Text, provider); +#endif + #endregion } } diff --git a/DomainModeling.Tests/WrapperValueObjectTests.cs b/DomainModeling.Tests/WrapperValueObjectTests.cs index 0c577d3..c34eec2 100644 --- a/DomainModeling.Tests/WrapperValueObjectTests.cs +++ b/DomainModeling.Tests/WrapperValueObjectTests.cs @@ -467,7 +467,8 @@ 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.Equal("", ((StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue))).ToString(format: null, formatProvider: null)); + // Cannot be helped - see comments in IFormattableWrapper + Assert.Null(((StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue))).ToString(format: null, formatProvider: null)); } [Fact] @@ -491,6 +492,7 @@ public void SpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResult() Assert.Equal(1, charsWritten); Assert.Equal("5".AsSpan(), result); + // We succeeded at doing all we must - false is only for insufficient space Assert.True(((StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue))).TryFormat(result, out charsWritten, format: null, provider: null)); Assert.Equal(0, charsWritten); } @@ -516,6 +518,7 @@ public void UtfSpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResul Assert.Equal(1, bytesWritten); Assert.Equal("5"u8, result); + // We succeeded at doing all we must - false is only for insufficient space Assert.True(((StringValue)RuntimeHelpers.GetUninitializedObject(typeof(StringValue))).TryFormat(result, out bytesWritten, format: null, provider: null)); Assert.Equal(0, bytesWritten); } @@ -585,6 +588,16 @@ public void Utf8SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpected Assert.Equal("5", result4.Value?.Value.Value?.Value); Assert.Equal(result4, FormatAndParseTestingStringWrapper.Parse(input, provider: null)); } + + [Fact] + public void ParsabilityAndFormattability_InAllScenarios_ShouldBeGeneratedAccordingToTransitiveAvailability() + { + var interfaces = typeof(FormatAndParseTestingUriWrapper).GetInterfaces(); + Assert.Contains(interfaces, interf => interf.Name == "ISpanFormattable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "ISpanParsable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanFormattable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanParsable"); + } } // Use a namespace, since our source generators dislike nested types @@ -695,6 +708,14 @@ internal partial class FormatAndParseTestingNestedStringWrapper internal partial struct FormatAndParseTestingStringId : IComparable { } + [WrapperValueObject] + internal partial class FormatAndParseTestingUriWrapper : IComparable + { + public int CompareTo(FormatAndParseTestingUriWrapper? other) + { + throw new NotImplementedException("This exists only to allow an identity type based on this type."); + } + } [WrapperValueObject] internal partial class JsonTestingStringWrapper @@ -773,10 +794,11 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly /// Should merely compile. /// [WrapperValueObject] - [System.Text.Json.Serialization.JsonConverter(typeof(WrapperJsonConverter))] - [Newtonsoft.Json.JsonConverter(typeof(NewtonsoftWrapperJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(ValueWrapperJsonConverter))] + [Newtonsoft.Json.JsonConverter(typeof(ValueWrapperNewtonsoftJsonConverter))] internal sealed partial class FullySelfImplementedWrapperValueObject : WrapperValueObject, + IValueWrapper, IComparable, ISpanFormattable, ISpanParsable, @@ -798,6 +820,31 @@ private FullySelfImplementedWrapperValueObject() { } + static FullySelfImplementedWrapperValueObject IValueWrapper.Create(int value) + { + return new FullySelfImplementedWrapperValueObject(value); + } + + /// + /// Serializes a domain object as a plain value. + /// + int ISerializableDomainObject.Serialize() + { + return this.Value; + } + + /// + /// Deserializes a plain value back into a domain object, without using a parameterized constructor. + /// + static FullySelfImplementedWrapperValueObject ISerializableDomainObject.Deserialize(int value) + { +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable CS0618 // Obsolete constructor is intended for us + return new FullySelfImplementedWrapperValueObject() { Value = value }; +#pragma warning restore CS0618 +#pragma warning restore IDE0079 + } + public sealed override int GetHashCode() { return this.Value.GetHashCode(); @@ -825,24 +872,6 @@ 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); @@ -861,6 +890,8 @@ static FullySelfImplementedWrapperValueObject ISerializableDomainObject FormattingHelper.ToString(this.Value, format, formatProvider); @@ -894,6 +925,8 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid public static FullySelfImplementedWrapperValueObject Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => (FullySelfImplementedWrapperValueObject)ParsingHelper.Parse(utf8Text, provider); +#endif + #endregion } } diff --git a/DomainModeling/Conversions/FormattingHelper.cs b/DomainModeling/Conversions/FormattingHelper.cs index 440824a..81ba1a0 100644 --- a/DomainModeling/Conversions/FormattingHelper.cs +++ b/DomainModeling/Conversions/FormattingHelper.cs @@ -13,6 +13,9 @@ namespace Architect.DomainModeling.Conversions; /// 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. /// /// +//#if NET10_0_OR_GREATER +//[Obsolete("New default interface implementations and extension members alleviate the need for this helper.")] +//#endif public static class FormattingHelper { /// @@ -30,14 +33,23 @@ public static string ToString(T? instance, /// /// Delegates to . /// + [return: NotNullIfNotNull(nameof(instance))] public static string ToString(T? instance, string? format, IFormatProvider? formatProvider) where T : IFormattable { - if (instance is null) - return ""; + // We exist to help fulfill IFormattable.ToString() + // We imitate its false promise that the string will be non-null if there is an instance + + // This is tricky if the underlying value is null, such as when a struct wraps a reference type and it is spawned with the "default" keyword + // TryFormat() does not have an issue: it is correct to write 0 chars when there is nothing to write + // ToString() does have an issue: it is incorrect to represent nothing as any string other than null + + // The problem originates from the interface: IFormattable.ToString() returning a non-nullable string is a false promise, as not every scenario can fulfill this with a correct answer + // Either this was an oversight by the .NET team, or they made a trade-off: a very occasional incorrectness of the nullability in exchange for simplicity for the vast majority of cases + // Either way, the most correct and accurate resolution is to return null after all (thus acknowledging the oversight or trade-off) - return instance.ToString(format, formatProvider); + return instance?.ToString(format, formatProvider)!; } #pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution @@ -52,10 +64,10 @@ public static string ToString(T? instance, /// 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 @@ -81,7 +93,7 @@ public static bool TryFormat(T? instance, if (instance is null) { charsWritten = 0; - return true; + return true; // We succeeded at doing all we must - false is only for insufficient space } return instance.TryFormat(destination, out charsWritten, format, provider); @@ -104,7 +116,7 @@ public static bool TryFormat(string? instance, charsWritten = 0; if (instance is null) - return true; + return true; // We succeeded at doing all we must - false is only for insufficient space if (instance.Length > destination.Length) return false; @@ -137,7 +149,7 @@ public static bool TryFormat(T? instance, if (instance is null) { bytesWritten = 0; - return true; + return true; // We succeeded at doing all we must - false is only for insufficient space } return instance.TryFormat(utf8Destination, out bytesWritten, format, provider); @@ -160,7 +172,7 @@ public static bool TryFormat(string instance, if (instance is null) { bytesWritten = 0; - return true; + return true; // We succeeded at doing all we must - false is only for insufficient space } return Utf8.FromUtf16(instance, utf8Destination, charsRead: out _, bytesWritten: out bytesWritten) == System.Buffers.OperationStatus.Done; diff --git a/DomainModeling/Conversions/IFormattableWrapper.cs b/DomainModeling/Conversions/IFormattableWrapper.cs new file mode 100644 index 0000000..d4c188b --- /dev/null +++ b/DomainModeling/Conversions/IFormattableWrapper.cs @@ -0,0 +1,135 @@ +using System.Runtime.CompilerServices; +using System.Text.Unicode; + +namespace Architect.DomainModeling.Conversions; + +/// +/// Provides default implementations for for an . +/// +public interface IFormattableWrapper : IFormattable + where TWrapper : IFormattableWrapper, IValueWrapper + where TValue : IFormattable? +{ + /// + /// Beware: . promises a non-null result, but not all cases can correctly fulfill that promise. + /// Specifically, a wrapper containing a null value can provide no correct answer here other than null. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) + { + // This is tricky if the underlying value is null, such as when a struct wraps a reference type and it is spawned with the "default" keyword + // TryFormat() does not have an issue: it is correct to write 0 chars when there is nothing to write + // ToString() does have an issue: it is incorrect to represent nothing as any string other than null + + // The problem originates from the interface: IFormattable.ToString() returning a non-nullable string is a false promise, as not every scenario can fulfill this with a correct answer + // Either this was an oversight by the .NET team, or they made a trade-off: a very occasional incorrectness of the nullability in exchange for simplicity for the vast majority of cases + // Either way, the most correct and accurate resolution is to return null after all (thus acknowledging the oversight or trade-off) + + var value = ((TWrapper)this).Value; + var result = value?.ToString(format, formatProvider); + return result!; + } +} + +/// +/// Provides default implementations for for an . +/// +public interface ISpanFormattableWrapper : IFormattableWrapper, ISpanFormattable + where TWrapper : ISpanFormattableWrapper, IValueWrapper + where TValue : ISpanFormattable? +{ + bool ISpanFormattable.TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + var value = ((TWrapper)this).Value; + if (value is null) + { + charsWritten = 0; + return true; // We succeeded at doing all we must - false is only for insufficient space + } + var result = value.TryFormat(destination, out charsWritten, format, provider); + return result; + } +} + +/// +/// Provides default implementations for for an . +/// +public interface IUtf8SpanFormattableWrapper : IUtf8SpanFormattable + where TWrapper : IUtf8SpanFormattableWrapper, IValueWrapper + where TValue : IUtf8SpanFormattable? +{ + bool IUtf8SpanFormattable.TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + { + var value = ((TWrapper)this).Value; + if (value is null) + { + bytesWritten = 0; + return true; // We succeeded at doing all we must - false is only for insufficient space + } + var result = value.TryFormat(utf8Destination, out bytesWritten, format, provider); + return result; + } +} + +#region Strings + +/// +/// Provides default implementations for for an wrapping a . +/// +public interface IFormattableStringWrapper : IFormattable + where TWrapper : IFormattableStringWrapper, IValueWrapper +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + string IFormattable.ToString(string? format, IFormatProvider? formatProvider) + { + var value = ((TWrapper)this).Value; + return value!; + } +} + +/// +/// Provides default implementations for for an wrapping a . +/// +public interface ISpanFormattableStringWrapper : IFormattableStringWrapper, ISpanFormattable + where TWrapper : ISpanFormattableStringWrapper, IValueWrapper +{ + bool ISpanFormattable.TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + var value = ((TWrapper)this).Value; + + charsWritten = 0; + + if (value is null) + return true; // We succeeded at doing all we must - false is only for insufficient space + + if (value.Length > destination.Length) + return false; + + value.AsSpan().CopyTo(destination); + charsWritten = value.Length; + return true; + } +} + +/// +/// Provides default implementations for for an wrapping a . +/// +public interface IUtf8SpanFormattableStringWrapper : IUtf8SpanFormattable + where TWrapper : IUtf8SpanFormattableStringWrapper, IValueWrapper +{ + bool IUtf8SpanFormattable.TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + { + var value = ((TWrapper)this).Value; + + if (value is null) + { + bytesWritten = 0; + return true; // We succeeded at doing all we must - false is only for insufficient space + } + + var success = Utf8.FromUtf16(value, utf8Destination, charsRead: out _, bytesWritten: out bytesWritten) == System.Buffers.OperationStatus.Done; + return success; + } +} + +#endregion diff --git a/DomainModeling/Conversions/IParsableWrapper.cs b/DomainModeling/Conversions/IParsableWrapper.cs new file mode 100644 index 0000000..57983bc --- /dev/null +++ b/DomainModeling/Conversions/IParsableWrapper.cs @@ -0,0 +1,150 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Unicode; + +namespace Architect.DomainModeling.Conversions; + +/// +/// Provides default implementations for for an . +/// +public interface IParsableWrapper : IParsable + where TWrapper : IParsableWrapper, IValueWrapper + where TValue : IParsable +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool IParsable.TryParse(string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) + { + result = default; + var success = TValue.TryParse(s, provider, out var value) && TWrapper.TryCreate(value, out result); + return success; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static TWrapper IParsable.Parse(string s, IFormatProvider? provider) + { + var value = TValue.Parse(s, provider); + var result = TWrapper.Create(value); + return result; + } +} + +/// +/// Provides default implementations for for an . +/// +public interface ISpanParsableWrapper : IParsableWrapper, ISpanParsable + where TWrapper : ISpanParsableWrapper, IValueWrapper + where TValue : ISpanParsable +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool ISpanParsable.TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) + { + result = default; + var success = TValue.TryParse(s, provider, out var value) && TWrapper.TryCreate(value, out result); + return success; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static TWrapper ISpanParsable.Parse(ReadOnlySpan s, IFormatProvider? provider) + { + var value = TValue.Parse(s, provider); + var result = TWrapper.Create(value); + return result; + } +} + +/// +/// Provides default implementations for for an . +/// +public interface IUtf8SpanParsableWrapper : IUtf8SpanParsable + where TWrapper : IUtf8SpanParsableWrapper, IValueWrapper + where TValue : IUtf8SpanParsable +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool IUtf8SpanParsable.TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) + { + result = default; + var success = TValue.TryParse(utf8Text, provider, out var value) && TWrapper.TryCreate(value, out result); + return success; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static TWrapper IUtf8SpanParsable.Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) + { + var value = TValue.Parse(utf8Text, provider); + var result = TWrapper.Create(value); + return result; + } +} + +#region Strings + +/// +/// Provides default implementations for for an wrapping a . +/// +public interface IParsableStringWrapper : IParsable + where TWrapper : IParsableStringWrapper, IValueWrapper +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool IParsable.TryParse(string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) + { + result = default; + var success = s is not null && TWrapper.TryCreate(s, out result); + return success; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static TWrapper IParsable.Parse(string s, IFormatProvider? provider) + { + var result = TWrapper.Create(s); + return result; + } +} + +/// +/// Provides default implementations for for an wrapping a . +/// +public interface ISpanParsableStringWrapper : IParsableStringWrapper, ISpanParsable + where TWrapper : ISpanParsableStringWrapper, IValueWrapper +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool ISpanParsable.TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) + { + var value = s.ToString(); + var success = TWrapper.TryCreate(value, out result); + return success; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static TWrapper ISpanParsable.Parse(ReadOnlySpan s, IFormatProvider? provider) + { + var value = s.ToString(); + var result = TWrapper.Create(value); + return result; + } +} + +/// +/// Provides default implementations for for an wrapping a . +/// +public interface IUtf8SpanParsableStringWrapper : IUtf8SpanParsable + where TWrapper : IUtf8SpanParsableStringWrapper, IValueWrapper +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool IUtf8SpanParsable.TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) + { + result = default; + var success = Utf8.IsValid(utf8Text) && TWrapper.TryCreate(Encoding.UTF8.GetString(utf8Text), out result); + return success; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static TWrapper IUtf8SpanParsable.Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) + { + var value = Encoding.UTF8.GetString(utf8Text); + var result = TWrapper.Create(value); + return result; + } +} + +#endregion diff --git a/DomainModeling/Conversions/IValueWrapper.cs b/DomainModeling/Conversions/IValueWrapper.cs new file mode 100644 index 0000000..f22e90e --- /dev/null +++ b/DomainModeling/Conversions/IValueWrapper.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Architect.DomainModeling.Conversions; + +/// +/// An instance of wrapping a value of type in a property named Value. +/// +/// The wrapper type. +/// The type being wrapped. +public interface IValueWrapper + where TWrapper : IValueWrapper +{ + // Note that a struct implementation can always return a null value + TValue? Value { get; } + + abstract static TWrapper Create( +#nullable disable // We are used interchangeably between types with nullable vs. non-nullable values, so do not enforce either + TValue value); +#nullable enable + + virtual static bool TryCreate( +#nullable disable // We are used interchangeably between types with nullable vs. non-nullable values, so do not enforce either + TValue value, +#nullable enable + [MaybeNullWhen(false)] out TWrapper result) + { + try + { + result = TWrapper.Create(value); + return true; + } + catch + { + result = default; + return false; + } + } +} diff --git a/DomainModeling/Conversions/ParsingHelper.cs b/DomainModeling/Conversions/ParsingHelper.cs index 7760841..fca7240 100644 --- a/DomainModeling/Conversions/ParsingHelper.cs +++ b/DomainModeling/Conversions/ParsingHelper.cs @@ -14,6 +14,9 @@ namespace Architect.DomainModeling.Conversions; /// 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. /// /// +//#if NET10_0_OR_GREATER +//[Obsolete("New default interface implementations and extension members alleviate the need for this helper.")] +//#endif public static class ParsingHelper { /// diff --git a/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs b/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs new file mode 100644 index 0000000..a5ad19e --- /dev/null +++ b/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs @@ -0,0 +1,167 @@ +#if NET10_0_OR_GREATER + +// Note: In the .NET 10 preview, this type resulted in: warning AD0001: Analyzer 'ILLink.RoslynAnalyzer.DynamicallyAccessedMembersAnalyzer' threw an exception of type 'System.InvalidCastException' with message 'Unable to cast object of type 'Microsoft.CodeAnalysis.CSharp.Symbols.PublicModel.NonErrorNamedTypeSymbol' to type 'Microsoft.CodeAnalysis.IMethodSymbol'.'. + +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Architect.DomainModeling.Conversions; + +/// +/// +/// Provides formatting methods on types marked with . +/// +/// +/// & co provide default interface implementations that alleviate the need to implement formatting methods manually for value wrappers. +/// However, that only works using explicit interface implementations, which can only be accessed through the interface or via generics. +/// +/// +/// These extensions provide access to the default interface implementations directly from the wrapper type. +/// +/// +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable CA1050 // Declare types in namespaces -- Lives in global namespace for visibility of extensions, on highly specific types +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ArchitectDomainModelingValueWrapperFormattingExtensions +{ + // #TODO: Remove outcommented!!! And simplify region names, if need regions at all. + #region IFormattable - Preferred + + extension(IValueWrapper wrapper) + where TWrapper : IFormattable, IValueWrapper + { + /// + /// Beware: . promises a non-null result, but not all cases can correctly fulfill that promise. + /// Specifically, a wrapper containing a null value can provide no correct answer here other than null. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public string ToString(string? format, IFormatProvider? formatProvider) + { + return ((TWrapper)wrapper).ToString(format, formatProvider); + } + } + + #endregion + + //#region IFormattable - String + + //extension(IValueWrapper wrapper) + // where TWrapper : IFormattable, IValueWrapper + //{ + // public string ToString(string? format, IFormatProvider? formatProvider) + // { + // return ((TWrapper)wrapper).ToString(format, formatProvider); + // } + //} + + //#endregion + +// #region IFormattable - Without IFormattable underlying value + +// extension(IValueWrapper wrapper) +// where TWrapper : ISpanFormattable, IValueWrapper +// { +//#pragma warning disable IDE0060 // Remove unused parameter -- Required for less-preferred overload resolution +// [Obsolete("This type must manually implement IFormattable, since the wrapped underlying type does not implement IFormattable.", error: true)] +// [OverloadResolutionPriority(Int32.MinValue)] +// public string ToString(string? format, IFormatProvider? formatProvider, +// [CallerLineNumber] int callerLineNumber = -1) +// { +// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support formatting."); +// } +//#pragma warning restore IDE0060 // Remove unused parameter +// } + +// #endregion + + #region ISpanFormattable - Preferred + + extension(IValueWrapper wrapper) + where TWrapper : ISpanFormattable, IValueWrapper + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + return ((TWrapper)wrapper).TryFormat(destination, out charsWritten, format, provider); + } + } + + #endregion + + //#region ISpanFormattable - String + + //extension(IValueWrapper wrapper) + // where TWrapper : ISpanFormattable, IValueWrapper + //{ + // public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + // { + // return ((TWrapper)wrapper).TryFormat(destination, out charsWritten, format, provider); + // } + //} + + //#endregion + +// #region ISpanFormattable - Without ISpanFormattable underlying value + +// extension(IValueWrapper wrapper) +// where TWrapper : ISpanFormattable, IValueWrapper +// { +//#pragma warning disable IDE0060 // Remove unused parameter -- Required for less-preferred overload resolution +// [Obsolete("This type must manually implement ISpanFormattable, since the wrapped underlying type does not implement ISpanFormattable.", error: true)] +// [OverloadResolutionPriority(Int32.MinValue)] +// public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider, +// [CallerLineNumber] int callerLineNumber = -1) +// { +// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support span formatting."); +// } +//#pragma warning restore IDE0060 // Remove unused parameter +// } + +// #endregion + + #region IUtf8SpanFormattable - Preferred + + extension(IValueWrapper wrapper) + where TWrapper : IUtf8SpanFormattable, IValueWrapper + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + { + return ((TWrapper)wrapper).TryFormat(utf8Destination, out bytesWritten, format, provider); + } + } + + #endregion + + //#region IUtf8SpanFormattable - String + + //extension(IValueWrapper wrapper) + // where TWrapper : IUtf8SpanFormattable, IValueWrapper + //{ + // public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) + // { + // return ((TWrapper)wrapper).TryFormat(utf8Destination, out bytesWritten, format, provider); + // } + //} + + //#endregion + +// #region IUtf8SpanFormattable - Without IUtf8SpanFormattable underlying value + +// extension(IValueWrapper wrapper) +// where TWrapper : IUtf8SpanFormattable, IValueWrapper +// { +//#pragma warning disable IDE0060 // Remove unused parameter -- Required for less-preferred overload resolution +// [Obsolete("This type must manually implement IUtf8SpanFormattable, since the wrapped underlying type does not implement IUtf8SpanFormattable.", error: true)] +// [OverloadResolutionPriority(Int32.MinValue)] +// public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider, +// [CallerLineNumber] int callerLineNumber = -1) +// { +// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support UTF-8 span formatting."); +// } +//#pragma warning restore IDE0060 // Remove unused parameter +// } + +// #endregion +} + +#endif diff --git a/DomainModeling/Conversions/WrapperJsonConverter.cs b/DomainModeling/Conversions/ValueWrapperJsonConverter.cs similarity index 98% rename from DomainModeling/Conversions/WrapperJsonConverter.cs rename to DomainModeling/Conversions/ValueWrapperJsonConverter.cs index 0578dde..5b7f56a 100644 --- a/DomainModeling/Conversions/WrapperJsonConverter.cs +++ b/DomainModeling/Conversions/ValueWrapperJsonConverter.cs @@ -10,7 +10,7 @@ namespace Architect.DomainModeling.Conversions; "Trimming", "IL2046", Justification = "JsonConverter read/write methods are not marked with RequiresUnreferencedCode, but overrides require unreferenced code due to serialization." )] -public sealed class WrapperJsonConverter< +public sealed class ValueWrapperJsonConverter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue> : System.Text.Json.Serialization.JsonConverter @@ -64,7 +64,7 @@ public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, "Trimming", "IL2046", Justification = "JsonConverter read/write methods are not marked with RequiresUnreferencedCode, but overrides require unreferenced code due to serialization." )] -public sealed class LargeNumberWrapperJsonConverter< +public sealed class LargeNumberValueWrapperJsonConverter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue> : System.Text.Json.Serialization.JsonConverter diff --git a/DomainModeling/Conversions/NewtonsoftWrapperJsonConverter.cs b/DomainModeling/Conversions/ValueWrapperNewtonsoftJsonConverter.cs similarity index 94% rename from DomainModeling/Conversions/NewtonsoftWrapperJsonConverter.cs rename to DomainModeling/Conversions/ValueWrapperNewtonsoftJsonConverter.cs index 1ee05ab..65b5d77 100644 --- a/DomainModeling/Conversions/NewtonsoftWrapperJsonConverter.cs +++ b/DomainModeling/Conversions/ValueWrapperNewtonsoftJsonConverter.cs @@ -4,9 +4,9 @@ namespace Architect.DomainModeling.Conversions; /// -/// A generic System.Text JSON converter for wrapper types, which serializes like the wrapped value itself. +/// A generic Newtonsoft JSON converter for wrapper types, which serializes like the wrapped value itself. /// -public sealed class NewtonsoftWrapperJsonConverter< +public sealed class ValueWrapperNewtonsoftJsonConverter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue> : Newtonsoft.Json.JsonConverter @@ -45,7 +45,7 @@ public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, /// This variant is intended for numeric types whose larger values risk truncation in languages such as JavaScript. /// It serializes to and from string. /// -public sealed class NewtonsoftLargeNumberWrapperJsonConverter< +public sealed class LargeNumberValueWrapperNewtonsoftJsonConverter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue> : Newtonsoft.Json.JsonConverter diff --git a/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs b/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs new file mode 100644 index 0000000..82f491a --- /dev/null +++ b/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs @@ -0,0 +1,159 @@ +#if NET10_0_OR_GREATER + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Architect.DomainModeling.Conversions; + +/// +/// +/// Provides parsing methods on types marked with . +/// +/// +/// & co provide default interface implementations that alleviate the need to implement parse methods manually for value wrappers. +/// However, that only works using explicit interface implementations, which can only be accessed through the interface or via generics. +/// +/// +/// These extensions provide access to the default interface implementations directly from the wrapper type. +/// +/// +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable CA1050 // Declare types in namespaces -- Lives in global namespace for visibility of extensions, on highly specific types +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ArchitectDomainModelingValueWrapperParsingExtensions +{ + // #TODO: Remove outcommented!!! And simplify region names, if need regions at all. + #region IParsable - Preferred + + extension(IValueWrapper wrapper) + where TWrapper : IParsable, IValueWrapper + { + [OverloadResolutionPriority(-1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(string s, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) + { + return TWrapper.TryParse(s, provider, out result); + } + + [OverloadResolutionPriority(-1)] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TWrapper Parse(string s, IFormatProvider? provider) + { + return TWrapper.Parse(s, provider); + } + } + + #endregion + + #region ISpanParsable - Preferred + + extension(IValueWrapper wrapper) + where TWrapper : ISpanParsable, IValueWrapper + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) + { + return TWrapper.TryParse(s, provider, out result); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TWrapper Parse(ReadOnlySpan s, IFormatProvider? provider) + { + return TWrapper.Parse(s, provider); + } + } + + #endregion + +// #region ISpanParsable - Without ISpanParsable underlying value + +// extension(IValueWrapper wrapper) +// where TWrapper : ISpanParsable, IValueWrapper +// { +//#pragma warning disable IDE0060 // Remove unused parameter -- Required for less-preferred overload resolution +// [Obsolete("This type must manually implement ISpanParsable, since the wrapped underlying type does not implement ISpanParsable.", error: true)] +// [OverloadResolutionPriority(Int32.MinValue)] +// public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result, +// [CallerLineNumber] int callerLineNumber = -1) +// { +// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support span parsing."); +// } + +// [Obsolete("ISpanParsable was not properly implemented on this type.", error: true)] +// [OverloadResolutionPriority(Int32.MinValue)] +// public static TWrapper Parse(ReadOnlySpan s, IFormatProvider? provider, +// [CallerLineNumber] int callerLineNumber = -1) +// { +// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support span parsing."); +// } +//#pragma warning restore IDE0060 // Remove unused parameter +// } + +// #endregion + + #region IUtf8SpanParsable - Preferred + + extension(IValueWrapper wrapper) + where TWrapper : IUtf8SpanParsable, IValueWrapper + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) + { + return TWrapper.TryParse(utf8Text, provider, out result); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TWrapper Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) + { + return TWrapper.Parse(utf8Text, provider); + } + } + + #endregion + + //#region IUtf8SpanParsable - String + + //extension(IValueWrapper wrapper) + // where TWrapper : IUtf8SpanParsable, IValueWrapper + //{ + // public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) + // { + // return TWrapper.TryParse(utf8Text, provider, out result); + // } + + // public static TWrapper Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) + // { + // return TWrapper.Parse(utf8Text, provider); + // } + //} + + //#endregion + +// #region IUtf8SpanParsable - Without IUtf8SpanParsable underlying value + +// extension(IValueWrapper wrapper) +// where TWrapper : IUtf8SpanParsable, IValueWrapper +// { +//#pragma warning disable IDE0060 // Remove unused parameter -- Required for less-preferred overload resolution +// [Obsolete("This type must manually implement IUtf8SpanParsable, since the wrapped underlying type does not implement IUtf8SpanParsable.", error: true)] +// [OverloadResolutionPriority(Int32.MinValue)] +// public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result, +// [CallerLineNumber] int callerLineNumber = -1) +// { +// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support UTF-8 span parsing."); +// } + +// [Obsolete("This type must manually implement IUtf8SpanParsable, since the wrapped underlying type does not implement IUtf8SpanParsable.", error: true)] +// [OverloadResolutionPriority(Int32.MinValue)] +// public static TWrapper Parse(ReadOnlySpan utf8Text, IFormatProvider? provider, +// [CallerLineNumber] int callerLineNumber = -1) +// { +// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support UTF-8 span parsing."); +// } +//#pragma warning restore IDE0060 // Remove unused parameter +// } + +// #endregion +} + +#endif diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 0c3d5bc..c87f6c2 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -23,7 +23,7 @@ - 3.0.3 + 4.0.0 A complete Domain-Driven Design (DDD) toolset for implementing domain models, including base types and source generators. @@ -33,13 +33,24 @@ Release notes: 4.0.0: +Platform support - BREAKING: Platform support: Dropped support for .NET 6.0 and .NET 7.0 (EOL). + +The base class is dead. Long live the interface! + +Performance +- Enhancement: Reduced assembly size by having source-generated WrapperValueObject/Identity types use generic JSON serializers instead of generating their own. +- Enhancement: Reduced assembly size by having source-generated WrapperValueObject/Identity types use default interface implementations and/or extension members instead of generating various components. +- Enhancement: Improved (compile-time) source generator performance. + +Misc improvements - Semi-breaking: IIdentity now implements IWrapperValueObject. -- Enhancement: Generated domain objects now use generic JSON serializers instead of generating their own, reducing assembly size. -- Enhancement: Improved correctness of trimming. +- Semi-breaking: I[Utf8][Span]Formattable implementations based on strings have stopped treating null strings as "", as this could cover up mistakes instead of revealing them. - Bug fix: Fixed a bug where source-generated records would always generate ToString()/Equals()/GetHashCode(), even if you wrote your own. +- Bug fix: Fixed a bug where source-generated WrapperValueObject/Identity types would not recognize manual member implementations if they were explicit interface implementations. - Buf fix: Fixed a bug where the DummyBuilder generator struggled with nested types. -- Bug fix: Fixed a compile-time bug where the analyzer would not properly warn that source generation on nested types is unsupported. +- Bug fix: Fixed a bug where the analyzer would not properly warn that source generation on nested types is unsupported. +- Enhancement: Improved correctness of trimming. 3.0.3: From 749978412153552cc48075e3dfb5eff247ce5326 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:05:32 +0200 Subject: [PATCH 07/23] Removed outcommented code. --- .../ValueWrapperFormattingExtensions.cs | 106 ------------------ .../ValueWrapperParsingExtensions.cs | 83 -------------- 2 files changed, 189 deletions(-) diff --git a/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs b/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs index a5ad19e..f4c78ff 100644 --- a/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs +++ b/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs @@ -23,9 +23,6 @@ [EditorBrowsable(EditorBrowsableState.Never)] public static class ArchitectDomainModelingValueWrapperFormattingExtensions { - // #TODO: Remove outcommented!!! And simplify region names, if need regions at all. - #region IFormattable - Preferred - extension(IValueWrapper wrapper) where TWrapper : IFormattable, IValueWrapper { @@ -40,41 +37,6 @@ public string ToString(string? format, IFormatProvider? formatProvider) } } - #endregion - - //#region IFormattable - String - - //extension(IValueWrapper wrapper) - // where TWrapper : IFormattable, IValueWrapper - //{ - // public string ToString(string? format, IFormatProvider? formatProvider) - // { - // return ((TWrapper)wrapper).ToString(format, formatProvider); - // } - //} - - //#endregion - -// #region IFormattable - Without IFormattable underlying value - -// extension(IValueWrapper wrapper) -// where TWrapper : ISpanFormattable, IValueWrapper -// { -//#pragma warning disable IDE0060 // Remove unused parameter -- Required for less-preferred overload resolution -// [Obsolete("This type must manually implement IFormattable, since the wrapped underlying type does not implement IFormattable.", error: true)] -// [OverloadResolutionPriority(Int32.MinValue)] -// public string ToString(string? format, IFormatProvider? formatProvider, -// [CallerLineNumber] int callerLineNumber = -1) -// { -// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support formatting."); -// } -//#pragma warning restore IDE0060 // Remove unused parameter -// } - -// #endregion - - #region ISpanFormattable - Preferred - extension(IValueWrapper wrapper) where TWrapper : ISpanFormattable, IValueWrapper { @@ -85,41 +47,6 @@ public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan } } - #endregion - - //#region ISpanFormattable - String - - //extension(IValueWrapper wrapper) - // where TWrapper : ISpanFormattable, IValueWrapper - //{ - // public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) - // { - // return ((TWrapper)wrapper).TryFormat(destination, out charsWritten, format, provider); - // } - //} - - //#endregion - -// #region ISpanFormattable - Without ISpanFormattable underlying value - -// extension(IValueWrapper wrapper) -// where TWrapper : ISpanFormattable, IValueWrapper -// { -//#pragma warning disable IDE0060 // Remove unused parameter -- Required for less-preferred overload resolution -// [Obsolete("This type must manually implement ISpanFormattable, since the wrapped underlying type does not implement ISpanFormattable.", error: true)] -// [OverloadResolutionPriority(Int32.MinValue)] -// public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider, -// [CallerLineNumber] int callerLineNumber = -1) -// { -// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support span formatting."); -// } -//#pragma warning restore IDE0060 // Remove unused parameter -// } - -// #endregion - - #region IUtf8SpanFormattable - Preferred - extension(IValueWrapper wrapper) where TWrapper : IUtf8SpanFormattable, IValueWrapper { @@ -129,39 +56,6 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly return ((TWrapper)wrapper).TryFormat(utf8Destination, out bytesWritten, format, provider); } } - - #endregion - - //#region IUtf8SpanFormattable - String - - //extension(IValueWrapper wrapper) - // where TWrapper : IUtf8SpanFormattable, IValueWrapper - //{ - // public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) - // { - // return ((TWrapper)wrapper).TryFormat(utf8Destination, out bytesWritten, format, provider); - // } - //} - - //#endregion - -// #region IUtf8SpanFormattable - Without IUtf8SpanFormattable underlying value - -// extension(IValueWrapper wrapper) -// where TWrapper : IUtf8SpanFormattable, IValueWrapper -// { -//#pragma warning disable IDE0060 // Remove unused parameter -- Required for less-preferred overload resolution -// [Obsolete("This type must manually implement IUtf8SpanFormattable, since the wrapped underlying type does not implement IUtf8SpanFormattable.", error: true)] -// [OverloadResolutionPriority(Int32.MinValue)] -// public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider, -// [CallerLineNumber] int callerLineNumber = -1) -// { -// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support UTF-8 span formatting."); -// } -//#pragma warning restore IDE0060 // Remove unused parameter -// } - -// #endregion } #endif diff --git a/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs b/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs index 82f491a..ed71376 100644 --- a/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs +++ b/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs @@ -22,9 +22,6 @@ [EditorBrowsable(EditorBrowsableState.Never)] public static class ArchitectDomainModelingValueWrapperParsingExtensions { - // #TODO: Remove outcommented!!! And simplify region names, if need regions at all. - #region IParsable - Preferred - extension(IValueWrapper wrapper) where TWrapper : IParsable, IValueWrapper { @@ -43,10 +40,6 @@ public static TWrapper Parse(string s, IFormatProvider? provider) } } - #endregion - - #region ISpanParsable - Preferred - extension(IValueWrapper wrapper) where TWrapper : ISpanParsable, IValueWrapper { @@ -63,36 +56,6 @@ public static TWrapper Parse(ReadOnlySpan s, IFormatProvider? provider) } } - #endregion - -// #region ISpanParsable - Without ISpanParsable underlying value - -// extension(IValueWrapper wrapper) -// where TWrapper : ISpanParsable, IValueWrapper -// { -//#pragma warning disable IDE0060 // Remove unused parameter -- Required for less-preferred overload resolution -// [Obsolete("This type must manually implement ISpanParsable, since the wrapped underlying type does not implement ISpanParsable.", error: true)] -// [OverloadResolutionPriority(Int32.MinValue)] -// public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result, -// [CallerLineNumber] int callerLineNumber = -1) -// { -// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support span parsing."); -// } - -// [Obsolete("ISpanParsable was not properly implemented on this type.", error: true)] -// [OverloadResolutionPriority(Int32.MinValue)] -// public static TWrapper Parse(ReadOnlySpan s, IFormatProvider? provider, -// [CallerLineNumber] int callerLineNumber = -1) -// { -// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support span parsing."); -// } -//#pragma warning restore IDE0060 // Remove unused parameter -// } - -// #endregion - - #region IUtf8SpanParsable - Preferred - extension(IValueWrapper wrapper) where TWrapper : IUtf8SpanParsable, IValueWrapper { @@ -108,52 +71,6 @@ public static TWrapper Parse(ReadOnlySpan utf8Text, IFormatProvider? provi return TWrapper.Parse(utf8Text, provider); } } - - #endregion - - //#region IUtf8SpanParsable - String - - //extension(IValueWrapper wrapper) - // where TWrapper : IUtf8SpanParsable, IValueWrapper - //{ - // public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result) - // { - // return TWrapper.TryParse(utf8Text, provider, out result); - // } - - // public static TWrapper Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) - // { - // return TWrapper.Parse(utf8Text, provider); - // } - //} - - //#endregion - -// #region IUtf8SpanParsable - Without IUtf8SpanParsable underlying value - -// extension(IValueWrapper wrapper) -// where TWrapper : IUtf8SpanParsable, IValueWrapper -// { -//#pragma warning disable IDE0060 // Remove unused parameter -- Required for less-preferred overload resolution -// [Obsolete("This type must manually implement IUtf8SpanParsable, since the wrapped underlying type does not implement IUtf8SpanParsable.", error: true)] -// [OverloadResolutionPriority(Int32.MinValue)] -// public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out TWrapper result, -// [CallerLineNumber] int callerLineNumber = -1) -// { -// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support UTF-8 span parsing."); -// } - -// [Obsolete("This type must manually implement IUtf8SpanParsable, since the wrapped underlying type does not implement IUtf8SpanParsable.", error: true)] -// [OverloadResolutionPriority(Int32.MinValue)] -// public static TWrapper Parse(ReadOnlySpan utf8Text, IFormatProvider? provider, -// [CallerLineNumber] int callerLineNumber = -1) -// { -// throw new NotSupportedException($"Type {typeof(TWrapper).Name} does not support UTF-8 span parsing."); -// } -//#pragma warning restore IDE0060 // Remove unused parameter -// } - -// #endregion } #endif From af7089580619be7f22791a4a125e5e335146106b Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 3 Sep 2025 22:50:33 +0200 Subject: [PATCH 08/23] Generator performance and cleanup. --- ...ModelConfiguratorGenerator.DomainEvents.cs | 6 +- ...mainModelConfiguratorGenerator.Entities.cs | 6 +- ...inModelConfiguratorGenerator.Identities.cs | 6 +- ...nfiguratorGenerator.WrapperValueObjects.cs | 6 +- .../EntityFrameworkConfigurationGenerator.cs | 22 +- DomainModeling.Generator/Constants.cs | 18 - .../DomainEventGenerator.cs | 4 +- .../DomainModeling.Generator.csproj | 9 +- .../DummyBuilderGenerator.cs | 32 +- DomainModeling.Generator/EntityGenerator.cs | 4 +- DomainModeling.Generator/IdentityGenerator.cs | 90 +-- .../NamespaceSymbolExtensions.cs | 11 - .../TypeSymbolExtensions.cs | 603 +++++++++++------- .../ValueObjectGenerator.cs | 12 +- .../WrapperValueObjectGenerator.cs | 78 +-- .../DomainModeling.Tests.csproj | 8 +- DomainModeling/DomainModeling.csproj | 22 +- 17 files changed, 507 insertions(+), 430 deletions(-) delete mode 100644 DomainModeling.Generator/Constants.cs diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.DomainEvents.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.DomainEvents.cs index 20407c7..986c479 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.DomainEvents.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.DomainEvents.cs @@ -16,10 +16,10 @@ internal static void GenerateSourceForDomainEvents(SourceProductionContext conte 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")} }});")); + $"configurator.ConfigureDomainEvent<{generatable.ContainingNamespace}.{generatable.TypeName}>({Environment.NewLine} new Architect.DomainModeling.Configuration.IDomainEventConfigurator.Args() {{ HasDefaultConstructor = {(generatable.ExistingComponents.HasFlag(DomainEventGenerator.DomainEventTypeComponents.DefaultConstructor) ? "true" : "false")} }});")); var source = $@" -using {Constants.DomainModelingNamespace}; +using Architect.DomainModeling; #nullable enable @@ -35,7 +35,7 @@ public static class DomainEventDomainModelConfigurator /// 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) + public static void ConfigureDomainEvents(Architect.DomainModeling.Configuration.IDomainEventConfigurator configurator) {{ {configurationText} }} diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Entities.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Entities.cs index 81d4bcd..f072bb0 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Entities.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Entities.cs @@ -16,10 +16,10 @@ internal static void GenerateSourceForEntities(SourceProductionContext context, 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")} }});")); + $"configurator.ConfigureEntity<{generatable.ContainingNamespace}.{generatable.TypeName}>({Environment.NewLine} new Architect.DomainModeling.Configuration.IEntityConfigurator.Args() {{ HasDefaultConstructor = {(generatable.ExistingComponents.HasFlag(EntityGenerator.EntityTypeComponents.DefaultConstructor) ? "true" : "false")} }});")); var source = $@" -using {Constants.DomainModelingNamespace}; +using Architect.DomainModeling; #nullable enable @@ -35,7 +35,7 @@ public static class EntityDomainModelConfigurator /// 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) + public static void ConfigureEntities(Architect.DomainModeling.Configuration.IEntityConfigurator configurator) {{ {configurationText} }} diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs index 582e26d..8c1bc27 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs @@ -18,11 +18,11 @@ internal static void GenerateSourceForIdentities( var targetNamespace = input.Metadata.AssemblyName; var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable => $""" - configurator.ConfigureIdentity<{generatable.ContainingNamespace}.{generatable.TypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator.Args()); + configurator.ConfigureIdentity<{generatable.ContainingNamespace}.{generatable.TypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new Architect.DomainModeling.Configuration.IIdentityConfigurator.Args()); """)); var source = $@" -using {Constants.DomainModelingNamespace}; +using Architect.DomainModeling; #nullable enable @@ -38,7 +38,7 @@ public static class IdentityDomainModelConfigurator /// 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) + public static void ConfigureIdentities(Architect.DomainModeling.Configuration.IIdentityConfigurator configurator) {{ {configurationText} }} diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs index 17b38e2..32cd6a9 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs @@ -18,11 +18,11 @@ internal static void GenerateSourceForWrapperValueObjects( 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()); + configurator.ConfigureWrapperValueObject<{generatable.ContainingNamespace}.{generatable.TypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator.Args()); """)); var source = $@" -using {Constants.DomainModelingNamespace}; +using Architect.DomainModeling; #nullable enable @@ -38,7 +38,7 @@ public static class WrapperValueObjectDomainModelConfigurator /// 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) + public static void ConfigureWrapperValueObjects(Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator configurator) {{ {configurationText} }} diff --git a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs index d0ad05c..e4b592d 100644 --- a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs +++ b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs @@ -57,7 +57,7 @@ private static bool IsConfigureConventions(GeneratorSyntaxContext context, Cance methodSymbol.Name == "ConfigureConventions" && methodSymbol.IsOverride && methodSymbol.Parameters.Length == 1 && - methodSymbol.Parameters[0].Type.IsType("ModelConfigurationBuilder", "Microsoft.EntityFrameworkCore")) + methodSymbol.Parameters[0].Type.IsType("ModelConfigurationBuilder", "Microsoft", "EntityFrameworkCore")) return true; return false; @@ -140,8 +140,8 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; -using {Constants.DomainModelingNamespace}; -using {Constants.DomainModelingNamespace}.Conversions; +using Architect.DomainModeling; +using Architect.DomainModeling.Conversions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -257,10 +257,10 @@ file sealed record class DomainModelConfigurator( : IDomainModelConfigurator; file sealed record class EntityFrameworkIdentityConfigurator(ModelConfigurationBuilder ConfigurationBuilder) - : {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator + : Architect.DomainModeling.Configuration.IIdentityConfigurator {{ public void ConfigureIdentity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, TUnderlying>( - in {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator.Args _) + in Architect.DomainModeling.Configuration.IIdentityConfigurator.Args _) where TIdentity : IIdentity, ISerializableDomainObject where TUnderlying : notnull, IEquatable, IComparable {{ @@ -294,10 +294,10 @@ public IdentityValueObjectConverter() file sealed record class EntityFrameworkWrapperValueObjectConfigurator( ModelConfigurationBuilder ConfigurationBuilder) - : {Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator + : Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator {{ public void ConfigureWrapperValueObject<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue>( - in {Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator.Args _) + in Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator.Args _) where TWrapper : IWrapperValueObject, ISerializableDomainObject where TValue : notnull {{ @@ -325,7 +325,7 @@ public WrapperValueObjectConverter() file sealed record class EntityFrameworkEntityConfigurator( Action InvokeConfigurationCallbacks) - : {Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator, {Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator, IEntityTypeAddedConvention, IModelFinalizingConvention + : Architect.DomainModeling.Configuration.IEntityConfigurator, Architect.DomainModeling.Configuration.IDomainEventConfigurator, IEntityTypeAddedConvention, IModelFinalizingConvention {{ private Dictionary EntityTypeConventionsByType {{ get; }} = new Dictionary(); @@ -342,7 +342,7 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven }} public void ConfigureEntity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TEntity>( - in {Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator.Args args) + in Architect.DomainModeling.Configuration.IEntityConfigurator.Args args) where TEntity : IEntity {{ if (!this.EntityTypeConventionsByType.TryGetValue(typeof(TEntity), out var entityTypeConvention)) @@ -355,7 +355,7 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven }} public void ConfigureDomainEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TDomainEvent>( - in {Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator.Args args) + in Architect.DomainModeling.Configuration.IDomainEventConfigurator.Args args) where TDomainEvent : IDomainObject {{ if (!this.EntityTypeConventionsByType.TryGetValue(typeof(TDomainEvent), out var entityTypeConvention)) @@ -401,7 +401,7 @@ public override InstantiationBinding With(IReadOnlyList parame }} "; - AddSource(context, source, "EntityFrameworkDomainModelConfigurationExtensions", $"{Constants.DomainModelingNamespace}.EntityFramework"); + AddSource(context, source, "EntityFrameworkDomainModelConfigurationExtensions", $"Architect.DomainModeling.EntityFramework"); } internal sealed record Generatable diff --git a/DomainModeling.Generator/Constants.cs b/DomainModeling.Generator/Constants.cs deleted file mode 100644 index 2e90555..0000000 --- a/DomainModeling.Generator/Constants.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Architect.DomainModeling.Generator; - -internal static class Constants -{ - public const string DomainModelingNamespace = "Architect.DomainModeling"; - 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 index f0e9fff..07f9d20 100644 --- a/DomainModeling.Generator/DomainEventGenerator.cs +++ b/DomainModeling.Generator/DomainEventGenerator.cs @@ -49,7 +49,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; // Only with the attribute - if (type.GetAttribute("DomainEventAttribute", Constants.DomainModelingNamespace, arity: 0) is null) + if (type.GetAttribute("DomainEventAttribute", "Architect.DomainModeling", arity: 0) is null) return null; // Only concrete @@ -67,7 +67,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella var result = new Generatable() { TypeLocation = type.Locations.FirstOrDefault(), - IsDomainObject = type.IsOrImplementsInterface(type => type.IsType(Constants.DomainObjectInterfaceName, Constants.DomainModelingNamespace, arity: 0), out _), + IsDomainObject = type.IsOrImplementsInterface(type => type.IsType("IDomainObject", "Architect", "DomainModeling", arity: 0), out _), TypeName = type.Name, // Non-generic by filter ContainingNamespace = type.ContainingNamespace.ToString(), }; diff --git a/DomainModeling.Generator/DomainModeling.Generator.csproj b/DomainModeling.Generator/DomainModeling.Generator.csproj index 0b2cee9..0c780c4 100644 --- a/DomainModeling.Generator/DomainModeling.Generator.csproj +++ b/DomainModeling.Generator/DomainModeling.Generator.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -22,11 +22,8 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + diff --git a/DomainModeling.Generator/DummyBuilderGenerator.cs b/DomainModeling.Generator/DummyBuilderGenerator.cs index 326dd5c..f552ad2 100644 --- a/DomainModeling.Generator/DummyBuilderGenerator.cs +++ b/DomainModeling.Generator/DummyBuilderGenerator.cs @@ -44,7 +44,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; // Only with the attribute - if (type.GetAttribute("DummyBuilderAttribute", Constants.DomainModelingNamespace, arity: 1) is not AttributeData { AttributeClass: not null } attribute) + if (type.GetAttribute("DummyBuilderAttribute", "Architect.DomainModeling", arity: 1) is not AttributeData { AttributeClass: not null } attribute) return null; var modelType = attribute.AttributeClass.TypeArguments[0]; @@ -121,7 +121,7 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr context.CancellationToken.ThrowIfCancellationRequested(); var type = compilation.GetTypeByMetadataName(builder.TypeFullMetadataName); - var modelType = type?.GetAttribute("DummyBuilderAttribute", Constants.DomainModelingNamespace, arity: 1) is AttributeData { AttributeClass: not null } attribute + var modelType = type?.GetAttribute("DummyBuilderAttribute", "Architect.DomainModeling", arity: 1) is AttributeData { AttributeClass: not null } attribute ? attribute.AttributeClass.TypeArguments[0] : null; @@ -227,48 +227,48 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr componentBuilder.Append("// "); componentBuilder.AppendLine($" public {typeName} With{memberName}({param.Type.WithNullableAnnotation(NullableAnnotation.None)} value) => this.With(b => b.{memberName} = value);"); - foreach (var primitiveType in param.Type.GetAvailableConversionsFromPrimitives(skipForSystemTypes: true)) + foreach (var (primitiveSpecialType, primitiveType) in param.Type.EnumerateAvailableConversionsFromPrimitives(skipForSpecialTypes: true)) { - if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType(primitiveType))) + if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.SpecialType == primitiveSpecialType)) componentBuilder.Append("// "); componentBuilder.AppendLine($" public {typeName} With{memberName}({primitiveType} value, bool _ = false) => this.With{memberName}(({param.Type.WithNullableAnnotation(NullableAnnotation.None)})value);"); } - if (param.Type.IsType() || param.Type.IsType()) + if (param.Type.SpecialType == SpecialType.System_DateTime || param.Type.IsSystemType("DateTimeOffset")) { - if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType())) + if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.SpecialType == SpecialType.System_String)) componentBuilder.Append("// "); componentBuilder.AppendLine($" public {typeName} With{memberName}(System.String value) => this.With{memberName}(DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal));"); } - if (param.Type.IsNullable(out var underlyingType) && (underlyingType.IsType() || underlyingType.IsType())) + if (param.Type.IsNullable(out var underlyingType) && (underlyingType.SpecialType == SpecialType.System_DateTime || underlyingType.IsSystemType("DateTimeOffset"))) { - if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType())) + if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.SpecialType == SpecialType.System_String)) componentBuilder.Append("// "); componentBuilder.AppendLine($" public {typeName} With{memberName}(System.String value, bool _ = false) => this.With{memberName}(value is null ? null : DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal));"); } - if (param.Type.IsType("DateOnly", "System")) + if (param.Type.IsSystemType("DateOnly")) { - if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType())) + if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.SpecialType == SpecialType.System_String)) componentBuilder.Append("// "); componentBuilder.AppendLine($" public {typeName} With{memberName}(System.String value) => this.With{memberName}(DateOnly.Parse(value, CultureInfo.InvariantCulture));"); } - if (param.Type.IsNullable(out underlyingType) && underlyingType.IsType("DateOnly", "System")) + if (param.Type.IsNullable(out underlyingType) && underlyingType.IsSystemType("DateOnly")) { - if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType())) + if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.SpecialType == SpecialType.System_String)) componentBuilder.Append("// "); componentBuilder.AppendLine($" public {typeName} With{memberName}(System.String value, bool _ = false) => this.With{memberName}(value is null ? null : DateOnly.Parse(value, CultureInfo.InvariantCulture));"); } - if (param.Type.IsType("TimeOnly", "System")) + if (param.Type.IsSystemType("TimeOnly")) { - if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType())) + if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.SpecialType == SpecialType.System_String)) componentBuilder.Append("// "); componentBuilder.AppendLine($" public {typeName} With{memberName}(System.String value) => this.With{memberName}(TimeOnly.Parse(value, CultureInfo.InvariantCulture));"); } - if (param.Type.IsNullable(out underlyingType) && underlyingType.IsType("TimeOnly", "System")) + if (param.Type.IsNullable(out underlyingType) && underlyingType.IsSystemType("TimeOnly")) { - if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType())) + if (membersByName[$"With{memberName}"].Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && method.Parameters[0].Type.SpecialType == SpecialType.System_String)) componentBuilder.Append("// "); componentBuilder.AppendLine($" public {typeName} With{memberName}(System.String value, bool _ = false) => this.With{memberName}(value is null ? null : TimeOnly.Parse(value, CultureInfo.InvariantCulture));"); } diff --git a/DomainModeling.Generator/EntityGenerator.cs b/DomainModeling.Generator/EntityGenerator.cs index 6216ecc..f5ff483 100644 --- a/DomainModeling.Generator/EntityGenerator.cs +++ b/DomainModeling.Generator/EntityGenerator.cs @@ -49,7 +49,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; // Only with the attribute - if (type.GetAttribute("EntityAttribute", Constants.DomainModelingNamespace, arity: 0) is null) + if (type.GetAttribute("EntityAttribute", "Architect.DomainModeling", arity: 0) is null) return null; // Only concrete @@ -67,7 +67,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella var result = new Generatable() { TypeLocation = type.Locations.FirstOrDefault(), - IsEntity = type.IsOrImplementsInterface(type => type.IsType(Constants.EntityInterfaceName, Constants.DomainModelingNamespace, arity: 0), out _), + IsEntity = type.IsOrImplementsInterface(type => type.IsType("IEntity", "Architect", "DomainModeling", arity: 0), out _), TypeName = type.Name, // Non-generic by filter ContainingNamespace = type.ContainingNamespace.ToString(), }; diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index f036c2c..ec72779 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -81,20 +81,20 @@ internal void Generate(IncrementalGeneratorInitializationContext context, private static bool LooksLikeEntity(INamedTypeSymbol type) { - var result = type.IsOrInheritsClass(baseType => baseType.Name == Constants.EntityTypeName, out _); + var result = type.IsOrInheritsClass(baseType => baseType.Name == "Entity", out _); return result; } private static bool IsEntity(INamedTypeSymbol type, out INamedTypeSymbol entityInterface) { - var result = type.IsOrInheritsClass(baseType => baseType.Arity == 2 && baseType.IsType(Constants.EntityTypeName, Constants.DomainModelingNamespace), out entityInterface); + var result = type.IsOrInheritsClass(baseType => baseType.Arity == 2 && baseType.IsType("Entity", "Architect", "DomainModeling"), out entityInterface); return result; } private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeData attribute) { attribute = null!; - if (type.GetAttribute("IdentityValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) is AttributeData { AttributeClass: not null } attributeOutput) + if (type.GetAttribute("IdentityValueObjectAttribute", "Architect.DomainModeling", arity: 1) is AttributeData { AttributeClass: not null } attributeOutput) attribute = attributeOutput; return attribute != null; } @@ -115,7 +115,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // Consider any type with SOME 2-param generic "Entity" inheritance/implementation foreach (var baseType in cds.BaseList.Types) { - if (baseType.Type.HasArityAndName(2, Constants.EntityTypeName)) + if (baseType.Type.HasArityAndName(2, "Entity")) return true; } } @@ -176,8 +176,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella 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.IsIIdentity = type.IsOrImplementsInterface(interf => interf.IsType("IIdentity", "Architect", "DomainModeling", arity: 1), out _); + result.IsSerializableDomainObject = type.IsOrImplementsInterface(type => type.IsType("ISerializableDomainObject", "Architect", "DomainModeling", arity: 2), out _); result.IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); result.IsRecord = type.IsRecord; result.IsStruct = type.TypeKind == TypeKind.Struct; @@ -211,7 +211,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // 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.Arity == 0 && method.Parameters.Length == 1 && - method.Parameters[0].Type.IsType())); + method.Parameters[0].Type.SpecialType == SpecialType.System_Object)); // Records override this, but our implementation is superior existingComponents |= IdTypeComponents.EqualsMethod.If(members.Any(member => @@ -275,90 +275,90 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= IdTypeComponents.NullableConvertToOperator.If(members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && - method.ReturnType.IsType(nameof(Nullable), "System") && method.ReturnType.HasSingleGenericTypeArgument(type) && + method.ReturnType.IsNullableOf(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)))); + : method.Parameters[0].Type.IsNullableOf(underlyingType)))); existingComponents |= IdTypeComponents.NullableConvertFromOperator.If(members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && (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))); + : method.ReturnType.IsNullableOf(underlyingType) && + method.Parameters[0].Type.IsNullableOf(type)))); existingComponents |= IdTypeComponents.SerializeToUnderlying.If(members.Any(member => - member.Name.EndsWith($".{Constants.SerializeDomainObjectMethodName}") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 0)); + member.HasNameOrExplicitInterfaceImplementationName("Serialize") && 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 && + member.HasNameOrExplicitInterfaceImplementationName("Deserialize") && 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)); + attribute.AttributeClass?.IsTypeWithNamespace("JsonConverterAttribute", "System.Text.Json.Serialization") == true)); existingComponents |= IdTypeComponents.NewtonsoftJsonConverter.If(type.GetAttributes().Any(attribute => - attribute.AttributeClass?.IsType("JsonConverterAttribute", "Newtonsoft.Json") == true)); + attribute.AttributeClass?.IsType("JsonConverterAttribute", "Newtonsoft", "Json") == true)); existingComponents |= IdTypeComponents.StringComparison.If(members.Any(member => member.Name == "StringComparison")); 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())); + method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.ParsableTryParseMethod.If(members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && member.HasNameOrExplicitInterfaceImplementationName("TryParse") && - method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType() && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); existingComponents |= IdTypeComponents.ParsableParseMethod.If(members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && member.HasNameOrExplicitInterfaceImplementationName("Parse") && - method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType())); + method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.SpanFormattableTryFormatMethod.If(members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && - 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())); + method.Parameters[0].Type.IsSpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[1].Type.SpecialType == SpecialType.System_Int32 && method.Parameters[1].RefKind == RefKind.Out && + method.Parameters[2].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[3].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.SpanParsableTryParseMethod.If(members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && member.HasNameOrExplicitInterfaceImplementationName("TryParse") && - method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && - method.Parameters[1].Type.IsType(typeof(IFormatProvider)) && + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); existingComponents |= IdTypeComponents.SpanParsableParseMethod.If(members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && member.HasNameOrExplicitInterfaceImplementationName("Parse") && - method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && - method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.Utf8SpanFormattableTryFormatMethod.If(members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && - 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())); + method.Parameters[0].Type.IsSpanOfSpecialType(SpecialType.System_Byte) && + method.Parameters[1].Type.SpecialType == SpecialType.System_Int32 && method.Parameters[1].RefKind == RefKind.Out && + method.Parameters[2].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[3].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.Utf8SpanParsableTryParseMethod.If(members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && member.HasNameOrExplicitInterfaceImplementationName("TryParse") && - method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && - method.Parameters[1].Type.IsType(typeof(IFormatProvider)) && + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Byte) && + method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); existingComponents |= IdTypeComponents.Utf8SpanParsableParseMethod.If(members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && member.HasNameOrExplicitInterfaceImplementationName("Parse") && - method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && - method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Byte) && + method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.CreateMethod.If(members.Any(member => member is IMethodSymbol method && method.IsStatic && method.Arity == 0 && method.Parameters.Length == 1 && @@ -368,17 +368,17 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella result.ExistingComponents = existingComponents; } - result.ToStringExpression = underlyingType.CreateStringExpression("Value"); + result.ToStringExpression = underlyingType.CreateValueToStringExpression(); 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)"); result.UnderlyingTypeFullyQualifiedName = underlyingType.ToString(); result.IsToStringNullable = underlyingType.IsToStringNullable() || result.ToStringExpression.Contains('?'); - result.UnderlyingTypeIsINumber = underlyingType.IsOrImplementsInterface(interf => interf.IsType("INumber", "System.Numerics", arity: 1), out _); - result.UnderlyingTypeIsString = underlyingType.IsType(); + result.UnderlyingTypeIsINumber = underlyingType.IsOrImplementsInterface(interf => interf.IsSystemType("INumber", "Numerics", arity: 1), out _); + result.UnderlyingTypeIsString = underlyingType.SpecialType == SpecialType.System_String; 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.UnderlyingTypeIsNumericUnsuitableForJson = underlyingType.SpecialType is SpecialType.System_Decimal or SpecialType.System_UInt64 or SpecialType.System_Int64 || + underlyingType.IsSystemType("BigInteger", "Numerics") || underlyingType.IsSystemType("UInt128") || underlyingType.IsSystemType("Int128"); result.UnderlyingTypeIsStruct = underlyingType.IsValueType; return result; @@ -502,8 +502,8 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using {Constants.DomainModelingNamespace}; -using {Constants.DomainModelingNamespace}.Conversions; +using Architect.DomainModeling; +using Architect.DomainModeling.Conversions; #nullable enable @@ -521,7 +521,7 @@ namespace {containingNamespace} {(hasIdentityValueObjectAttribute ? "" : $"[IdentityValueObject<{underlyingTypeFullyQualifiedName}>]")} {(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} : - {Constants.IdentityInterfaceTypeName}<{underlyingTypeFullyQualifiedName}>, + IIdentity<{underlyingTypeFullyQualifiedName}>, IValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}>, @@ -529,7 +529,7 @@ namespace {containingNamespace} {(isSpanParsable ? "" : "//")}ISpanParsable<{idTypeName}>, ISpanParsable{formattableParsableWrapperSuffix}, {(isUtf8SpanFormattable ? "" : "//")}IUtf8SpanFormattable, IUtf8SpanFormattable{formattableParsableWrapperSuffix}, {(isUtf8SpanParsable ? "" : "//")}IUtf8SpanParsable<{idTypeName}>, IUtf8SpanParsable{formattableParsableWrapperSuffix}, - {Constants.SerializableDomainObjectInterfaceTypeName}<{idTypeName}, {underlyingTypeFullyQualifiedName}> + ISerializableDomainObject<{idTypeName}, {underlyingTypeFullyQualifiedName}> {{ {(existingComponents.HasFlags(IdTypeComponents.Value) ? "/*" : "")} {nonNullStringSummary} @@ -555,7 +555,7 @@ namespace {containingNamespace} /// /// Serializes a domain object as a plain value. /// - {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")} {Constants.SerializableDomainObjectInterfaceTypeName}<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Serialize() + {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")} ISerializableDomainObject<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Serialize() {{ return this.Value; }} @@ -565,7 +565,7 @@ namespace {containingNamespace} /// /// Deserializes a plain value back into a domain object, without using a parameterized constructor. /// - static {idTypeName} {Constants.SerializableDomainObjectInterfaceTypeName}<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Deserialize({underlyingTypeFullyQualifiedName} value) + static {idTypeName} ISerializableDomainObject<{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);" : "")} diff --git a/DomainModeling.Generator/NamespaceSymbolExtensions.cs b/DomainModeling.Generator/NamespaceSymbolExtensions.cs index 8ec5969..2be18aa 100644 --- a/DomainModeling.Generator/NamespaceSymbolExtensions.cs +++ b/DomainModeling.Generator/NamespaceSymbolExtensions.cs @@ -7,17 +7,6 @@ namespace Architect.DomainModeling.Generator; /// internal static class NamespaceSymbolExtensions { - /// - /// Returns whether the given is or resides in the System namespace. - /// - public static bool IsInSystemNamespace(this INamespaceSymbol namespaceSymbol) - { - while (namespaceSymbol?.ContainingNamespace is not null) - namespaceSymbol = namespaceSymbol.ContainingNamespace; - - return namespaceSymbol?.Name == "System"; - } - /// /// Returns whether the given has the given . /// diff --git a/DomainModeling.Generator/TypeSymbolExtensions.cs b/DomainModeling.Generator/TypeSymbolExtensions.cs index ff39df1..c12e068 100644 --- a/DomainModeling.Generator/TypeSymbolExtensions.cs +++ b/DomainModeling.Generator/TypeSymbolExtensions.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; namespace Architect.DomainModeling.Generator; @@ -22,50 +23,208 @@ public static string GetFullMetadataName(this INamedTypeSymbol namedTypeSymbol) return $"{GetFullMetadataName(namedTypeSymbol.ContainingType)}+{namedTypeSymbol.MetadataName}"; // Beware that types may exist in the global namespace - return namedTypeSymbol.ContainingNamespace is INamespaceSymbol ns && !ns.IsGlobalNamespace + return namedTypeSymbol.ContainingNamespace is INamespaceSymbol { IsGlobalNamespace: false } ns ? $"{ns.ToDisplayString()}.{namedTypeSymbol.MetadataName}" : namedTypeSymbol.MetadataName; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsSystemType(this ITypeSymbol typeSymbol, string typeName) + { + var result = typeSymbol.Name == typeName && typeSymbol.ContainingNamespace is { Name: "System", ContainingNamespace.IsGlobalNamespace: true }; + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsSystemType(this ITypeSymbol typeSymbol, string typeName, int arity) + { + var result = typeSymbol.Name == typeName && typeSymbol.ContainingNamespace is { Name: "System", ContainingNamespace.IsGlobalNamespace: true } && + typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.Arity == arity; + return result; + } + + /// A single intermediate namespace component, e.g. "Collections", but not "Collections.Generic". + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsSystemType(this ITypeSymbol typeSymbol, string typeName, string intermediateNamespace) + { + var result = typeSymbol.Name == typeName && typeSymbol.ContainingNamespace is { ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } } ns && ns.Name == intermediateNamespace; + return result; + } + + /// A single intermediate namespace component, e.g. "Collections", but not "Collections.Generic". + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsSystemType(this ITypeSymbol typeSymbol, string typeName, string intermediateNamespace, int arity) + { + var result = typeSymbol.Name == typeName && typeSymbol.ContainingNamespace is { ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } } ns && ns.Name == intermediateNamespace && + typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.Arity == arity; + return result; + } + + public static bool IsSystemType(this ITypeSymbol typeSymbol, string typeName, string intermediateNamespace1, string intermediateNamespace2) + { + var result = + typeSymbol.Name == typeName && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace: + { + Name: "System", + ContainingNamespace.IsGlobalNamespace: true, + } + } ns1 + } ns2 && + ns1.Name == intermediateNamespace1 && + ns2.Name == intermediateNamespace2; + return result; + } + + public static bool IsSystemType(this ITypeSymbol typeSymbol, string typeName, string intermediateNamespace1, string intermediateNamespace2, int arity) + { + var result = + typeSymbol.Name == typeName && + typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.Arity == arity && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace: + { + Name: "System", + ContainingNamespace.IsGlobalNamespace: true, + } + } ns1 + } ns2 && + ns1.Name == intermediateNamespace1 && + ns2.Name == intermediateNamespace2; + return result; + } + + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string namespaceComponent1) + { + var result = typeSymbol.Name == typeName && typeSymbol.ContainingNamespace is { ContainingNamespace.IsGlobalNamespace: true } ns1 && ns1.Name == namespaceComponent1; + return result; + } + + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string namespaceComponent1, int arity) + { + var result = typeSymbol.Name == typeName && typeSymbol.ContainingNamespace is { ContainingNamespace.IsGlobalNamespace: true } ns1 && ns1.Name == namespaceComponent1 && + typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.Arity == arity; + return result; + } + + public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2) + { + var result = + typeSymbol.Name == typeName && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace.IsGlobalNamespace: true, + } ns1 + } ns2 && + ns1.Name == namespaceComponent1 && + ns2.Name == namespaceComponent2; + return result; + } + + public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2, int arity) + { + var result = + typeSymbol.Name == typeName && + typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.Arity == arity && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace.IsGlobalNamespace: true, + } ns1 + } ns2 && + ns1.Name == namespaceComponent1 && + ns2.Name == namespaceComponent2; + return result; + } + + public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2, string namespaceComponent3) + { + var result = + typeSymbol.Name == typeName && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace: + { + ContainingNamespace.IsGlobalNamespace: true, + } ns1 + } ns2 + } ns3 && + ns1.Name == namespaceComponent1 && + ns2.Name == namespaceComponent2 && + ns3.Name == namespaceComponent3; + return result; + } + + public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2, string namespaceComponent3, int arity) + { + var result = + typeSymbol.Name == typeName && + typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.Arity == arity && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace: + { + ContainingNamespace.IsGlobalNamespace: true, + } ns1 + } ns2 + } ns3 && + ns1.Name == namespaceComponent1 && + ns2.Name == namespaceComponent2 && + ns3.Name == namespaceComponent3; + return result; + } + /// - /// Returns whether the is of type . + /// Returns whether the has the given and . /// - public static bool IsType(this ITypeSymbol typeSymbol) + public static bool IsTypeWithNamespace(this ITypeSymbol typeSymbol, string typeName, string containingNamespace, int? arity = null) { - return typeSymbol.IsType(typeof(T)); + return IsTypeWithNamespace(typeSymbol, typeName.AsSpan(), containingNamespace.AsSpan(), arity); } /// - /// Returns whether the is of type . + /// Returns whether the has the given and . /// - [Obsolete("Use ITypeSymbol.Equals(ITypeSymbol, SymbolEqualityComparer) instead.")] - public static bool IsType(this ITypeSymbol typeSymbol, ITypeSymbol comparand) + /// If not null, the being-generic of the type must match this value. + private static bool IsTypeWithNamespace(this ITypeSymbol typeSymbol, ReadOnlySpan typeName, ReadOnlySpan containingNamespace, int? arity = null) { - var containingNamespace = comparand.ContainingNamespace; - - Span freeBuffer = stackalloc char[128]; - ReadOnlySpan chars = freeBuffer; - - while (containingNamespace?.IsGlobalNamespace == false && freeBuffer.Length >= containingNamespace.Name.Length) - { - containingNamespace.Name.AsSpan().CopyTo(freeBuffer); - freeBuffer = freeBuffer.Slice(containingNamespace.Name.Length); - containingNamespace = containingNamespace.ContainingNamespace; - } + var backtickIndex = typeName.IndexOf('`'); + if (backtickIndex >= 0) + typeName = typeName.Slice(0, backtickIndex); - chars = chars.Slice(0, chars.Length - freeBuffer.Length); - if (containingNamespace?.IsGlobalNamespace != false) - chars = (typeSymbol.ContainingNamespace?.ToString() ?? "").AsSpan(); + var result = typeSymbol.Name.AsSpan().Equals(typeName, StringComparison.Ordinal) && + typeSymbol.ContainingNamespace.HasFullName(containingNamespace); - if (!typeSymbol.IsType(typeSymbol.Name.AsSpan(), chars)) - return false; + if (result && arity is not null) + result = typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.Arity == arity; - var namedTypeSymbol = typeSymbol as INamedTypeSymbol; - var namedComparand = comparand as INamedTypeSymbol; - if (namedTypeSymbol?.Arity > 0 && namedComparand?.Arity > 0) - return namedTypeSymbol.TypeArguments.SequenceEqual(namedComparand.TypeArguments, (left, right) => left.IsType(right)); + return result; + } - return (namedTypeSymbol?.Arity ?? -1) == (namedComparand?.Arity ?? -1); + /// + /// Returns whether the is of type . + /// + public static bool IsType(this ITypeSymbol typeSymbol) + { + return typeSymbol.IsType(typeof(T)); } /// @@ -75,7 +234,7 @@ public static bool IsType(this ITypeSymbol typeSymbol, Type type) { if (type.IsGenericTypeDefinition) ThrowOpenGenericTypeException(); - if (!IsType(typeSymbol, type.Name, type.Namespace)) return false; + if (!IsTypeWithNamespace(typeSymbol, type.Name, type.Namespace)) return false; return !type.IsGenericType || HasGenericTypeArguments(typeSymbol, type); @@ -103,48 +262,17 @@ 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, int? arity = null) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsSpanOfSpecialType(this ITypeSymbol typeSymbol, SpecialType specialType) { - var fullTypeNameSpan = fullTypeName.AsSpan(); - - var lastDotIndex = fullTypeNameSpan.LastIndexOf('.'); - - if (lastDotIndex < 1) return false; - - var typeName = fullTypeNameSpan.Slice(1 + lastDotIndex); - var containingNamespace = fullTypeNameSpan.Slice(0, lastDotIndex); - - return IsType(typeSymbol, typeName, containingNamespace, arity); - } - - /// - /// Returns whether the has the given and . - /// - public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string containingNamespace, int? arity = null) - { - return IsType(typeSymbol, typeName.AsSpan(), containingNamespace.AsSpan(), arity); + var result = typeSymbol.IsSystemType("Span") && typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.TypeArguments[0].SpecialType == specialType; + return result; } - /// - /// 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, int? arity = null) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsReadOnlySpanOfSpecialType(this ITypeSymbol typeSymbol, SpecialType specialType) { - var backtickIndex = typeName.IndexOf('`'); - if (backtickIndex >= 0) - typeName = typeName.Slice(0, backtickIndex); - - var result = typeSymbol.Name.AsSpan().Equals(typeName, StringComparison.Ordinal) && - typeSymbol.ContainingNamespace.HasFullName(containingNamespace); - - if (result && arity is not null) - result = typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.Arity == arity; - + var result = typeSymbol.IsSystemType("ReadOnlySpan") && typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.TypeArguments[0].SpecialType == specialType; return result; } @@ -164,7 +292,7 @@ public static bool IsOrInheritsClass(this ITypeSymbol typeSymbol, Func()) + if (baseType.SpecialType == SpecialType.System_Object) break; if (predicate(baseType)) @@ -205,41 +333,23 @@ public static bool IsOrImplementsInterface(this ITypeSymbol typeSymbol, Func - /// Returns whether the is a constructed generic type with a single type argument matching the . - /// - public static bool HasSingleGenericTypeArgument(this ITypeSymbol typeSymbol, ITypeSymbol requiredTypeArgument) - { - return typeSymbol is INamedTypeSymbol namedTypeSymbol && - namedTypeSymbol.TypeArguments.Length == 1 && - namedTypeSymbol.TypeArguments[0].Equals(requiredTypeArgument, SymbolEqualityComparer.Default); - } - - /// - /// Returns whether the represents an integral type, such as or . + /// Returns whether the represents one of the 8 primitive integral types, such as or . /// /// Whether to return true for a of a matching underlying type. - /// Whether to consider as an integral type. - public static bool IsIntegral(this ITypeSymbol typeSymbol, bool seeThroughNullable, bool includeDecimal = false) + public static bool IsPrimitiveIntegral(this ITypeSymbol typeSymbol, bool seeThroughNullable) { - if (typeSymbol.IsNullable(out var underlyingType) && seeThroughNullable) + if (seeThroughNullable && typeSymbol.IsNullable(out var underlyingType)) typeSymbol = underlyingType; - var result = typeSymbol.IsType() || - typeSymbol.IsType() || - typeSymbol.IsType() || - typeSymbol.IsType() || - typeSymbol.IsType() || - typeSymbol.IsType() || - typeSymbol.IsType() || - typeSymbol.IsType() || - (includeDecimal && typeSymbol.IsType()); - + var specialType = typeSymbol.SpecialType; + var result = specialType >= SpecialType.System_SByte && specialType <= SpecialType.System_UInt64; return result; } /// /// Returns whether the is a nested type. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsNested(this ITypeSymbol typeSymbol) { var result = typeSymbol.ContainingType is not null; @@ -249,22 +359,20 @@ public static bool IsNested(this ITypeSymbol typeSymbol) /// /// Returns whether the is a generic type. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsGeneric(this ITypeSymbol typeSymbol) { - if (typeSymbol is not INamedTypeSymbol namedTypeSymbol) return false; - - var result = namedTypeSymbol.IsGenericType; + var result = typeSymbol is INamedTypeSymbol { IsGenericType: true }; return result; } /// /// Returns whether the is a generic type with the given number of type parameters. /// - public static bool IsGeneric(this ITypeSymbol typeSymbol, int typeParameterCount) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsGeneric(this ITypeSymbol typeSymbol, int arity) { - if (typeSymbol is not INamedTypeSymbol namedTypeSymbol) return false; - - var result = namedTypeSymbol.IsGenericType && namedTypeSymbol.Arity == typeParameterCount; + var result = typeSymbol is INamedTypeSymbol { IsGenericType: true } namedTypeSymbol && namedTypeSymbol.Arity == arity; return result; } @@ -272,13 +380,14 @@ public static bool IsGeneric(this ITypeSymbol typeSymbol, int typeParameterCount /// Returns whether the is a generic type with the given number of type parameters. /// Outputs the type arguments on true. /// - public static bool IsGeneric(this ITypeSymbol typeSymbol, int typeParameterCount, out ImmutableArray typeArguments) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsGeneric(this ITypeSymbol typeSymbol, int arity, out ImmutableArray typeArguments) { - typeArguments = default; - - if (typeSymbol is not INamedTypeSymbol namedTypeSymbol) return false; - - if (!IsGeneric(typeSymbol, typeParameterCount)) return false; + if (typeSymbol is not INamedTypeSymbol { IsGenericType: true } namedTypeSymbol || namedTypeSymbol.Arity != arity) + { + typeArguments = default; + return false; + } typeArguments = namedTypeSymbol.TypeArguments; return true; @@ -287,17 +396,19 @@ public static bool IsGeneric(this ITypeSymbol typeSymbol, int typeParameterCount /// /// Returns whether the is a . /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsNullable(this ITypeSymbol typeSymbol) { - return typeSymbol.IsNullable(out _); + return typeSymbol is INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T }; } /// /// Returns whether the is a , outputting the underlying type if so. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsNullable(this ITypeSymbol typeSymbol, out ITypeSymbol underlyingType) { - if (typeSymbol.IsValueType && typeSymbol is INamedTypeSymbol namedTypeSymbol && typeSymbol.IsType("System.Nullable", arity: 1)) + if (typeSymbol is INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } namedTypeSymbol) { underlyingType = namedTypeSymbol.TypeArguments[0]; return true; @@ -308,11 +419,13 @@ public static bool IsNullable(this ITypeSymbol typeSymbol, out ITypeSymbol under } /// - /// Returns whether the given implements against itself. + /// Returns whether the is a , where T matches . /// - public static bool IsSelfEquatable(this ITypeSymbol typeSymbol) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNullableOf(this ITypeSymbol typeSymbol, ITypeSymbol underlyingType) { - return typeSymbol.IsOrImplementsInterface(interf => interf.IsType("IEquatable", "System", arity: 1) && interf.HasSingleGenericTypeArgument(typeSymbol), out _); + var result = IsNullable(typeSymbol, out var comparand) && underlyingType.Equals(comparand, SymbolEqualityComparer.Default); + return result; } /// @@ -330,74 +443,70 @@ public static bool IsComparable(this ITypeSymbol typeSymbol, bool seeThroughNull if (seeThroughNullable && typeSymbol.IsNullable(out var underlyingType)) typeSymbol = underlyingType; - var result = typeSymbol.AllInterfaces.Any(interf => interf.IsType("System.IComparable")); + var result = typeSymbol.AllInterfaces.Any(interf => interf.IsSystemType("IComparable")); return result; } /// - /// Returns whether the is or implements . - /// If so, this method outputs the element type of the most concrete type it implements, if any. + /// Returns whether the is or implements and a most specific such interface can be identified. + /// For example, if is implemented for multiple types but is implemented for only one, there is a clear winner. /// - public static bool IsEnumerable(this ITypeSymbol typeSymbol, out INamedTypeSymbol? elementType) + public static bool IsSpecificGenericEnumerable(this ITypeSymbol typeSymbol, out INamedTypeSymbol? elementType) { - elementType = null; + elementType = default; - 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; // Does not work for nested arrays - return elementType is not null; - } - 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", arity: 1), out interf)) - { - elementType = interf.TypeArguments[0] as INamedTypeSymbol; - return true; - } - 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", arity: 1), out interf)) - { - elementType = interf.TypeArguments[0] as INamedTypeSymbol; - return true; - } - 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", arity: 1), out interf)) - { - elementType = interf.TypeArguments[0] as INamedTypeSymbol; - return true; - } - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IEnumerable", "System.Collections.Generic", arity: 1), out interf)) + if (typeSymbol is IArrayTypeSymbol { Rank: 1, ElementType: INamedTypeSymbol arrayElementType }) // Single-dimensional, non-nested array { - elementType = interf.TypeArguments[0] as INamedTypeSymbol; + elementType = arrayElementType; return true; } + var interfaces = typeSymbol.AllInterfaces; + + Span specialTypes = stackalloc SpecialType[1 + interfaces.Length]; + + // Put the SpecialType of each interface in the corresponding slot + for (var i = 0; i < interfaces.Length; i++) + specialTypes[i] = interfaces[i].ConstructedFrom.SpecialType; + + // Put the type itself in the additional slot at the end + specialTypes[specialTypes.Length - 1] = typeSymbol is INamedTypeSymbol + ? typeSymbol.SpecialType + : SpecialType.None; + + var indexOfMostSpecificCollectionInterface = + GetIndexOfSoleSpecialTypeMatch(specialTypes, SpecialType.System_Collections_Generic_IList_T) ?? + GetIndexOfSoleSpecialTypeMatch(specialTypes, SpecialType.System_Collections_Generic_ICollection_T) ?? + GetIndexOfSoleSpecialTypeMatch(specialTypes, SpecialType.System_Collections_Generic_IReadOnlyList_T) ?? + GetIndexOfSoleSpecialTypeMatch(specialTypes, SpecialType.System_Collections_Generic_IReadOnlyCollection_T) ?? + GetIndexOfSoleSpecialTypeMatch(specialTypes, SpecialType.System_Collections_Generic_IEnumerable_T); + + if (indexOfMostSpecificCollectionInterface is null) + return false; + + elementType = indexOfMostSpecificCollectionInterface == specialTypes.Length - 1 // The input type, rather than one of its interfaces + ? ((INamedTypeSymbol)typeSymbol).TypeArguments[0] as INamedTypeSymbol + : interfaces[indexOfMostSpecificCollectionInterface.Value].TypeArguments[0] as INamedTypeSymbol; + 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; + // Local function that returns the index of the single matching special type, or null if there is not exactly one match + static int? GetIndexOfSoleSpecialTypeMatch(ReadOnlySpan specialTypes, SpecialType specialType) + { + var match = (int?)null; + for (var i = 0; i < specialTypes.Length; i++) + { + if (specialTypes[i] != specialType) + continue; + + // Multiple matches + if (match != null) + return null; + + match = i; + } + return match; + } } /// @@ -406,8 +515,8 @@ public static ITypeSymbol ExtractNonArrayElementType(this IArrayTypeSymbol array public static bool HasEqualsOverride(this ITypeSymbol typeSymbol) { // Technically this could match an overridden "new" Equals defined by a base type, but that is a nonsense scenario - var result = typeSymbol.GetMembers(nameof(Object.Equals)).OfType().Any(method => method.IsOverride && !method.IsStatic && - method.Arity == 0 && method.Parameters.Length == 1 && method.Parameters[0].Type.IsType()); + var result = typeSymbol.GetMembers(nameof(Object.Equals)).OfType().Any(method => + method.IsOverride && !method.IsStatic && method.Arity == 0 && method.Parameters.Length == 1 && method.Parameters[0].Type.SpecialType == SpecialType.System_Object); return result; } @@ -426,7 +535,7 @@ public static bool HasEqualsOverride(this ITypeSymbol typeSymbol) /// public static AttributeData? GetAttribute(this ITypeSymbol typeSymbol, string typeName, string containingNamespace, int? arity = null) { - var result = typeSymbol.GetAttribute(attribute => (arity is null || attribute.Arity == arity) && attribute.IsType(typeName, containingNamespace)); + var result = typeSymbol.GetAttribute(attribute => (arity is null || attribute.Arity == arity) && attribute.IsTypeWithNamespace(typeName, containingNamespace)); return result; } @@ -442,58 +551,75 @@ public static bool HasEqualsOverride(this ITypeSymbol typeSymbol) /// /// Returns whether the defines a conversion to the specified type. /// - public static bool HasConversionTo(this ITypeSymbol typeSymbol, string typeName, string containingNamespace) + public static bool HasConversionTo(this ITypeSymbol typeSymbol, SpecialType specialType) { - var result = !typeSymbol.IsType(typeName, containingNamespace) && typeSymbol.GetMembers().Any(member => + var result = typeSymbol.SpecialType != specialType && typeSymbol.GetMembers().Any(member => member is IMethodSymbol method && ConversionOperatorNames.Contains(method.Name) && member.DeclaredAccessibility == Accessibility.Public && - method.ReturnType.IsType(typeName, containingNamespace)); + method.ReturnType.SpecialType == specialType); return result; } /// /// Returns whether the defines a conversion from the specified type. /// - public static bool HasConversionFrom(this ITypeSymbol typeSymbol, string typeName, string containingNamespace) + public static bool HasConversionFrom(this ITypeSymbol typeSymbol, SpecialType specialType) { - var result = !typeSymbol.IsType(typeName, containingNamespace) && typeSymbol.GetMembers().Any(member => + var result = typeSymbol.SpecialType != specialType && typeSymbol.GetMembers().Any(member => member is IMethodSymbol method && ConversionOperatorNames.Contains(method.Name) && member.DeclaredAccessibility == Accessibility.Public && - method.Parameters.Length == 1 && method.Parameters[0].Type.IsType(typeName, containingNamespace)); + method.Parameters.Length == 1 && method.Parameters[0].Type.SpecialType == specialType); return result; } /// /// Enumerates the primitive types (string, int, bool, etc.) from which the given is convertible. /// - /// If true, if the given type is directly under the System namespace, this method yields nothing. - public static IEnumerable GetAvailableConversionsFromPrimitives(this ITypeSymbol typeSymbol, bool skipForSystemTypes) + /// If true, if the given type is itself a special type, this method yields nothing. + public static IEnumerable<(SpecialType, Type)> EnumerateAvailableConversionsFromPrimitives(this ITypeSymbol typeSymbol, bool skipForSpecialTypes) { - if (skipForSystemTypes && typeSymbol.ContainingNamespace.HasFullName("System") && (typeSymbol.ContainingNamespace.ContainingNamespace?.IsGlobalNamespace ?? true)) + if (skipForSpecialTypes && typeSymbol.SpecialType != SpecialType.None) yield break; - if (typeSymbol.HasConversionFrom("String", "System")) yield return typeof(string); + if (typeSymbol.HasConversionFrom(SpecialType.System_String)) yield return (SpecialType.System_String, typeof(string)); - if (typeSymbol.HasConversionFrom("Boolean", "System")) yield return typeof(bool); + if (typeSymbol.HasConversionFrom(SpecialType.System_Boolean)) yield return (SpecialType.System_Boolean, typeof(bool)); - if (typeSymbol.HasConversionFrom("Byte", "System")) yield return typeof(byte); - if (typeSymbol.HasConversionFrom("SByte", "System")) yield return typeof(sbyte); - if (typeSymbol.HasConversionFrom("UInt16", "System")) yield return typeof(ushort); - if (typeSymbol.HasConversionFrom("Int16", "System")) yield return typeof(short); - if (typeSymbol.HasConversionFrom("UInt32", "System")) yield return typeof(uint); - if (typeSymbol.HasConversionFrom("Int32", "System")) yield return typeof(int); - if (typeSymbol.HasConversionFrom("UInt64", "System")) yield return typeof(ulong); - if (typeSymbol.HasConversionFrom("Int64", "System")) yield return typeof(long); + if (typeSymbol.HasConversionFrom(SpecialType.System_Byte)) yield return (SpecialType.System_Byte, typeof(byte)); + if (typeSymbol.HasConversionFrom(SpecialType.System_SByte)) yield return (SpecialType.System_SByte, typeof(sbyte)); + if (typeSymbol.HasConversionFrom(SpecialType.System_UInt16)) yield return (SpecialType.System_UInt16, typeof(ushort)); + if (typeSymbol.HasConversionFrom(SpecialType.System_Int16)) yield return (SpecialType.System_Int16, typeof(short)); + if (typeSymbol.HasConversionFrom(SpecialType.System_UInt32)) yield return (SpecialType.System_UInt32, typeof(uint)); + if (typeSymbol.HasConversionFrom(SpecialType.System_Int32)) yield return (SpecialType.System_Int32, typeof(int)); + if (typeSymbol.HasConversionFrom(SpecialType.System_UInt64)) yield return (SpecialType.System_UInt64, typeof(ulong)); + if (typeSymbol.HasConversionFrom(SpecialType.System_Int64)) yield return (SpecialType.System_Int64, typeof(long)); } /// - /// Returns the code for a string expression of the given of "this". + /// Returns the code for a ToString() expression of "this.Value". + /// + /// The expression to use for strings. + public static string CreateValueToStringExpression(this ITypeSymbol typeSymbol, string stringVariant = "this.Value") + { + return typeSymbol switch + { + { SpecialType: SpecialType.System_String } => stringVariant, + { IsValueType: true } and not INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } => "this.Value.ToString()", + _ => "this.Value?.ToString()", // Null-safety can be especially relevant for instances created with RuntimeHelpers.GetUninitializedObject() + }; + } + + /// + /// Returns the code for a ToString() expression of the given of "this". /// /// The member name. For example, "Value" leads to a string of "this.Value". /// The expression to use for strings. Any {0} is replaced by the member name. - public static string CreateStringExpression(this ITypeSymbol typeSymbol, string memberName, string stringVariant = "this.{0}") + public static string CreateToStringExpression(this ITypeSymbol typeSymbol, string memberName, string stringVariant = "this.{0}") { - 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 RuntimeHelpers.GetUninitializedObject() + return typeSymbol switch + { + { IsValueType: true } and not INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } => $"this.{memberName}.ToString()", + { SpecialType: SpecialType.System_String } => String.Format(stringVariant, memberName), + _ => $"this.{memberName}?.ToString()", // Null-safety can be especially relevant for instances created with RuntimeHelpers.GetUninitializedObject() + }; } /// @@ -503,7 +629,7 @@ public static bool IsToStringNullable(this ITypeSymbol typeSymbol) { if (typeSymbol.IsNullable()) return true; - var nullableAnnotation = typeSymbol.IsType() + var nullableAnnotation = typeSymbol.SpecialType == SpecialType.System_String ? typeSymbol.NullableAnnotation : typeSymbol.GetMembers(nameof(Object.ToString)).OfType().SingleOrDefault(method => !method.IsGenericMethod && method.Parameters.Length == 0)?.ReturnType.NullableAnnotation ?? NullableAnnotation.None; // Could inspect base members, but that is going a bit far @@ -520,39 +646,33 @@ public static string CreateHashCodeExpression(this ITypeSymbol typeSymbol, strin { // DO NOT REORDER - if (typeSymbol.IsType()) return String.Format(stringVariant, memberName); + if (typeSymbol.SpecialType == SpecialType.System_String) return String.Format(stringVariant, memberName); + + var typeOrNullableUnderlying = typeSymbol.IsNullable(out var nullableUnderlyingType) + ? nullableUnderlyingType + : typeSymbol; - 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})"; + if (typeOrNullableUnderlying.IsSystemType("Memory", arity: 1)) return $"{ComparisonsNamespace}.EnumerableComparer.GetMemoryHashCode(this.{memberName})"; + if (typeOrNullableUnderlying.IsSystemType("ReadOnlyMemory", 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", 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})"; + if (typeSymbol.IsSystemType("Dictionary", "Collections", "Generic", arity: 2)) return $"{ComparisonsNamespace}.DictionaryComparer.GetDictionaryHashCode(this.{memberName})"; + if (typeSymbol.IsOrImplementsInterface(type => type.IsSystemType("IDictionary", "Collections", "Generic", arity: 2), out var interf)) return $"{ComparisonsNamespace}.DictionaryComparer.GetDictionaryHashCode(({interf})this.{memberName})"; // Disambiguate + if (typeSymbol.IsOrImplementsInterface(type => type.IsSystemType("IReadOnlyDictionary", "Collections", "Generic", arity: 2), out _)) return $"{ComparisonsNamespace}.DictionaryComparer.GetDictionaryHashCode(this.{memberName})"; + if (typeSymbol.IsOrImplementsInterface(type => type.IsSystemType("ILookup", "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", arity: 0), out _))) + if ((!typeOrNullableUnderlying.HasEqualsOverride() || typeOrNullableUnderlying.IsOrImplementsInterface(type => type.IsSystemType("IStructuralEquatable", "Collections", arity: 0), out _)) && + typeOrNullableUnderlying.IsSpecificGenericEnumerable(out var elementType)) { if (elementType is not null) return $"{ComparisonsNamespace}.EnumerableComparer.GetEnumerableHashCode<{elementType}>(this.{memberName})"; else return $"{ComparisonsNamespace}.EnumerableComparer.GetEnumerableHashCode(this.{memberName})"; } - // 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", arity: 0), out _))) - { - if (elementType is not null) return $"{ComparisonsNamespace}.EnumerableComparer.GetEnumerableHashCode<{elementType}>(this.{memberName})"; - else return $"{ComparisonsNamespace}.EnumerableComparer.GetEnumerableHashCode(this.{memberName})"; - } - - if (typeSymbol.IsValueType && !typeSymbol.IsNullable()) return $"this.{memberName}.GetHashCode()"; + if (typeSymbol.IsValueType && nullableUnderlyingType is null) return $"this.{memberName}.GetHashCode()"; return $"(this.{memberName}?.GetHashCode() ?? 0)"; } @@ -568,43 +688,39 @@ public static string CreateEqualityExpression(this ITypeSymbol typeSymbol, strin // Not yet source-generated if (typeSymbol.TypeKind == TypeKind.Error) return $"Equals(this.{memberName}, other.{memberName})"; - if (typeSymbol.IsType()) return String.Format(stringVariant, memberName); + if (typeSymbol.SpecialType == SpecialType.System_String) return String.Format(stringVariant, memberName); - 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))"; + if (typeSymbol.IsSystemType("Memory", arity: 1)) return $"MemoryExtensions.SequenceEqual(this.{memberName}.Span, other.{memberName}.Span)"; + if (typeSymbol.IsSystemType("ReadOnlyMemory", arity: 1)) return $"MemoryExtensions.SequenceEqual(this.{memberName}.Span, other.{memberName}.Span)"; + if (typeSymbol.IsNullable(out var underlyingType) && underlyingType.IsSystemType("Memory", 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.IsSystemType("ReadOnlyMemory", 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", arity: 2)) + if (typeSymbol.IsSystemType("Dictionary", "Collections", "Generic", arity: 2)) return $"{ComparisonsNamespace}.DictionaryComparer.DictionaryEquals(this.{memberName}, other.{memberName})"; - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("IDictionary", "System.Collections.Generic", arity: 2), out var interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsSystemType("IDictionary", "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", arity: 2), out interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsSystemType("IReadOnlyDictionary", "Collections", "Generic", arity: 2), out interf)) return $"{ComparisonsNamespace}.DictionaryComparer.DictionaryEquals(this.{memberName}, other.{memberName})"; - if (typeSymbol.IsOrImplementsInterface(type => type.IsType("ILookup", "System.Linq", arity: 2), out interf)) + if (typeSymbol.IsOrImplementsInterface(type => type.IsSystemType("ILookup", "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", 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})"; - } + var typeOrNullableUnderlying = typeSymbol.IsNullable(out var nullableUnderlyingType) + ? nullableUnderlyingType + : typeSymbol; - // 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", arity: 0), out _))) + // 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 ((!typeOrNullableUnderlying.HasEqualsOverride() || typeOrNullableUnderlying.IsOrImplementsInterface(type => type.IsSystemType("IStructuralEquatable", "Collections", arity: 0), out _)) && + typeOrNullableUnderlying.IsSpecificGenericEnumerable(out var elementType)) { if (elementType is not null) return $"{ComparisonsNamespace}.EnumerableComparer.EnumerableEquals<{elementType}>(this.{memberName}, other.{memberName})"; else return $"{ComparisonsNamespace}.EnumerableComparer.EnumerableEquals(this.{memberName}, other.{memberName})"; } - if (typeSymbol.IsNullable()) return $"(this.{memberName} is null || other.{memberName} is null ? this.{memberName} is null & other.{memberName} is null : this.{memberName}.Value.Equals(other.{memberName}.Value))"; + if (nullableUnderlyingType is not null) return $"(this.{memberName} is null || other.{memberName} is null ? this.{memberName} is null & other.{memberName} is null : this.{memberName}.Value.Equals(other.{memberName}.Value))"; if (typeSymbol.IsValueType) return $"this.{memberName}.Equals(other.{memberName})"; return $"(this.{memberName}?.Equals(other.{memberName}) ?? other.{memberName} is null)"; } @@ -623,7 +739,7 @@ public static string CreateComparisonExpression(this ITypeSymbol typeSymbol, str // Collections have not been implemented, as we do not generate CompareTo() if any data member is not IComparable (as is the case for collections) - if (typeSymbol.IsType()) return String.Format(stringVariant, memberName); + if (typeSymbol.SpecialType == SpecialType.System_String) return String.Format(stringVariant, memberName); if (typeSymbol.IsNullable()) return $"(this.{memberName} is null || other.{memberName} is null ? -(this.{memberName} is null).CompareTo(other.{memberName} is null) : this.{memberName}.Value.CompareTo(other.{memberName}.Value))"; if (typeSymbol.IsValueType) return $"this.{memberName}.CompareTo(other.{memberName})"; return $"(this.{memberName} is null || other.{memberName} is null ? -(this.{memberName} is null).CompareTo(other.{memberName} is null) : this.{memberName}.CompareTo(other.{memberName}))"; @@ -669,22 +785,23 @@ 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.GetAttribute("WrapperValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) ?? - typeSymbol.GetAttribute("IdentityValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1)) + if ((typeSymbol.GetAttribute("WrapperValueObjectAttribute", "Architect.DomainModeling", arity: 1) ?? + typeSymbol.GetAttribute("IdentityValueObjectAttribute", "Architect.DomainModeling", arity: 1)) is AttributeData wrapperAttribute) { return $"new {typeSymbol.WithNullableAnnotation(NullableAnnotation.None)}({wrapperAttribute.AttributeClass!.TypeArguments[0].CreateDummyInstantiationExpression(symbolName, customizedTypes, createCustomTypeExpression, seenTypeSymbols)})"; } - if (typeSymbol.IsType()) return $@"""{symbolName.ToTitleCase()}"""; - if (typeSymbol.IsType() || (typeSymbol.IsNullable(out var underlyingType) && underlyingType.IsType())) return $"1m"; - if (typeSymbol.IsType() || (typeSymbol.IsNullable(out underlyingType) && underlyingType.IsType())) return $"new DateTime(2000, 01, 01, 00, 00, 00, DateTimeKind.Utc)"; - if (typeSymbol.IsType() || (typeSymbol.IsNullable(out underlyingType) && underlyingType.IsType())) return $"new DateTime(2000, 01, 01, 00, 00, 00, DateTimeKind.Utc)"; - if (typeSymbol.IsType("DateOnly", "System") || (typeSymbol.IsNullable(out underlyingType) && underlyingType.IsType("DateOnly", "System"))) return $"new DateOnly(2000, 01, 01)"; - if (typeSymbol.IsType("TimeOnly", "System") || (typeSymbol.IsNullable(out underlyingType) && underlyingType.IsType("TimeOnly", "System"))) return $"new TimeOnly(01, 00, 00)"; + if (typeSymbol.SpecialType == SpecialType.System_String) return $@"""{symbolName.ToTitleCase()}"""; + if (typeSymbol.SpecialType == SpecialType.System_Char) return "'1'"; + if (typeSymbol.SpecialType == SpecialType.System_Decimal) return "1m"; + if (typeSymbol.SpecialType == SpecialType.System_DateTime) return "new DateTime(2000, 01, 01, 00, 00, 00, DateTimeKind.Utc)"; + if (typeSymbol.IsSystemType("DateTimeOffset")) return "new DateTime(2000, 01, 01, 00, 00, 00, DateTimeKind.Utc)"; + if (typeSymbol.IsSystemType("DateOnly")) return "new DateOnly(2000, 01, 01)"; + if (typeSymbol.IsSystemType("TimeOnly")) return "new TimeOnly(01, 00, 00)"; if (typeSymbol.TypeKind == TypeKind.Enum) return typeSymbol.GetMembers().OfType().Any() ? $"{typeSymbol}.{typeSymbol.GetMembers().OfType().FirstOrDefault()!.Name}" : $"default({typeSymbol})"; if (typeSymbol.TypeKind == TypeKind.Array) return $"new[] {{ {((IArrayTypeSymbol)typeSymbol).ElementType.CreateDummyInstantiationExpression($"{symbolName}Element", customizedTypes, createCustomTypeExpression, seenTypeSymbols)} }}"; - if (typeSymbol.IsIntegral(seeThroughNullable: true, includeDecimal: true)) return $"({typeSymbol})1"; + if (typeSymbol.IsPrimitiveIntegral(seeThroughNullable: false) || typeSymbol.IsSystemType("UInt128") || typeSymbol.IsSystemType("Int128") || typeSymbol.IsSystemType("BigInteger", "Numerics")) return $"({typeSymbol})1"; if (typeSymbol is not INamedTypeSymbol namedTypeSymbol) return typeSymbol.IsReferenceType ? "null" : $"default({typeSymbol})"; var suitableCtor = namedTypeSymbol.Constructors diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index 029fba1..471b112 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -43,10 +43,10 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; // Only with the attribute - if (type.GetAttribute("ValueObjectAttribute", Constants.DomainModelingNamespace, arity: 0) is null) + if (type.GetAttribute("ValueObjectAttribute", "Architect.DomainModeling", arity: 0) is null) return null; - result.IsValueObject = type.IsOrImplementsInterface(type => type.IsType(Constants.ValueObjectInterfaceTypeName, Constants.DomainModelingNamespace, arity: 0), out _); + result.IsValueObject = type.IsOrImplementsInterface(type => type.IsType("IValueObject", "Architect", "DomainModeling", arity: 0), out _); result.IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); result.IsRecord = type.IsRecord; result.IsClass = type.TypeKind == TypeKind.Class; @@ -76,7 +76,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // 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())); + method.Parameters[0].Type.SpecialType == SpecialType.System_Object)); // Records override this, but our implementation is superior existingComponents |= ValueObjectTypeComponents.EqualsMethod.If(members.Any(member => @@ -142,7 +142,7 @@ 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.IsOrImplementsInterface(interf => interf.IsType("IComparable", "System", arity: 1) && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default), out _); + result.IsComparable = type.IsOrImplementsInterface(interf => interf.IsSystemType("IComparable", arity: 1) && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default), out _); result.IsComparable = result.IsComparable && dataMembers.All(tuple => tuple.Type.IsComparable(seeThroughNullable: true)); return result; @@ -263,7 +263,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using {Constants.DomainModelingNamespace}; +using Architect.DomainModeling; #nullable enable @@ -272,7 +272,7 @@ namespace {containingNamespace} /* 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()) + {(dataMembers.Any(member => member.Type.SpecialType == SpecialType.System_String) ? @"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) ? "*/" : "")} diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index ec4c622..b8c1b14 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -68,7 +68,7 @@ internal void Generate(IncrementalGeneratorInitializationContext context, private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeData attribute) { attribute = null!; - if (type.GetAttribute("WrapperValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) is AttributeData { AttributeClass: not null } attributeOutput) + if (type.GetAttribute("WrapperValueObjectAttribute", "Architect.DomainModeling", arity: 1) is AttributeData { AttributeClass: not null } attributeOutput) attribute = attributeOutput; return attribute != null; } @@ -105,8 +105,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella 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.IsWrapperValueObject = type.IsOrImplementsInterface(type => type.IsType("IWrapperValueObject", "Architect", "DomainModeling", arity: 1), out _); + result.IsSerializableDomainObject = type.IsOrImplementsInterface(type => type.IsType("ISerializableDomainObject", "Architect", "DomainModeling", arity: 2), out _); result.IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); result.IsRecord = type.IsRecord; result.IsClass = type.TypeKind == TypeKind.Class; @@ -118,7 +118,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella result.TypeName = type.Name; // Will be non-generic if we pass the conditions to proceed with generation result.ContainingNamespace = type.ContainingNamespace.ToString(); - result.ToStringExpression = underlyingType.CreateStringExpression("Value"); + result.ToStringExpression = underlyingType.CreateValueToStringExpression(); 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)"); @@ -126,15 +126,15 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella result.UnderlyingTypeKind = underlyingType.TypeKind; result.UnderlyingTypeIsStruct = underlyingType.IsValueType; result.UnderlyingTypeIsNullable = underlyingType.IsNullable(); - result.UnderlyingTypeIsString = underlyingType.IsType(); + result.UnderlyingTypeIsString = underlyingType.SpecialType == SpecialType.System_String; 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 // 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)) && + result.IsComparable = type.AllInterfaces.Any(interf => interf.IsSystemType("IComparable", 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; + result.IsComparable |= underlyingType.GetAttribute("IdentityValueObjectAttribute", "Architect.DomainModeling", arity: 1) is not null; var members = type.GetMembers(); @@ -161,7 +161,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // 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())); + method.Parameters[0].Type.SpecialType == SpecialType.System_Object)); // Records override this, but our implementation is superior existingComponents |= WrapperValueObjectTypeComponents.EqualsMethod.If(members.Any(member => @@ -227,29 +227,29 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella member is IMethodSymbol method && method.Parameters.Length == 1 && (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && method.ReturnType.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[0].Type.IsType(nameof(Nullable), "System") && method.Parameters[0].Type.HasSingleGenericTypeArgument(underlyingType))); + method.Parameters[0].Type.IsNullableOf(underlyingType))); // Consider having a reference-typed underlying type as already having the operator (though actually it does not apply at all) existingComponents |= WrapperValueObjectTypeComponents.NullableConvertFromOperator.If(!underlyingType.IsValueType || members.Any(member => member is IMethodSymbol method && method.Parameters.Length == 1 && (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && - method.ReturnType.IsType(nameof(Nullable), "System") && method.ReturnType.HasSingleGenericTypeArgument(underlyingType) && + method.ReturnType.IsNullableOf(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 && + member.HasNameOrExplicitInterfaceImplementationName("Serialize") && 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 && + member.HasNameOrExplicitInterfaceImplementationName("Deserialize") && 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)); + attribute.AttributeClass?.IsTypeWithNamespace("JsonConverterAttribute", "System.Text.Json.Serialization") == true)); existingComponents |= WrapperValueObjectTypeComponents.NewtonsoftJsonConverter.If(type.GetAttributes().Any(attribute => - attribute.AttributeClass?.IsType("JsonConverterAttribute", "Newtonsoft.Json") == true)); + attribute.AttributeClass?.IsType("JsonConverterAttribute", "Newtonsoft", "Json") == true)); existingComponents |= WrapperValueObjectTypeComponents.StringComparison.If(members.Any(member => member.Name == "StringComparison" && member.IsOverride)); @@ -257,67 +257,67 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella 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())); + method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= WrapperValueObjectTypeComponents.ParsableTryParseMethod.If( members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && member.HasNameOrExplicitInterfaceImplementationName("TryParse") && - method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType() && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); existingComponents |= WrapperValueObjectTypeComponents.ParsableParseMethod.If( members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && member.HasNameOrExplicitInterfaceImplementationName("Parse") && - method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType())); + method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= WrapperValueObjectTypeComponents.SpanFormattableTryFormatMethod.If( members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && - 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())); + method.Parameters[0].Type.IsSpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[1].Type.SpecialType == SpecialType.System_Int32 && method.Parameters[1].RefKind == RefKind.Out && + method.Parameters[2].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[3].Type.IsSystemType("IFormatProvider"))); existingComponents |= WrapperValueObjectTypeComponents.SpanParsableTryParseMethod.If( members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && member.HasNameOrExplicitInterfaceImplementationName("TryParse") && - method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && - method.Parameters[1].Type.IsType(typeof(IFormatProvider)) && + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); existingComponents |= WrapperValueObjectTypeComponents.SpanParsableParseMethod.If( members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && member.HasNameOrExplicitInterfaceImplementationName("Parse") && - method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && - method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanFormattableTryFormatMethod.If( members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && - 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())); + method.Parameters[0].Type.IsSpanOfSpecialType(SpecialType.System_Byte) && + method.Parameters[1].Type.SpecialType == SpecialType.System_Int32 && method.Parameters[1].RefKind == RefKind.Out && + method.Parameters[2].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[3].Type.IsSystemType("IFormatProvider"))); existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanParsableTryParseMethod.If( members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && member.HasNameOrExplicitInterfaceImplementationName("TryParse") && - method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && - method.Parameters[1].Type.IsType(typeof(IFormatProvider)) && + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Byte) && + method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanParsableParseMethod.If( members.Any(member => member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && member.HasNameOrExplicitInterfaceImplementationName("Parse") && - method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && - method.Parameters[1].Type.IsType(typeof(IFormatProvider)))); + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Byte) && + method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= WrapperValueObjectTypeComponents.CreateMethod.If(members.Any(member => member is IMethodSymbol method && method.IsStatic && method.Arity == 0 && method.Parameters.Length == 1 && @@ -426,8 +426,8 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using {Constants.DomainModelingNamespace}; -using {Constants.DomainModelingNamespace}.Conversions; +using Architect.DomainModeling; +using Architect.DomainModeling.Conversions; #nullable enable @@ -442,7 +442,7 @@ namespace {containingNamespace} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} /* Generated */ {generatable.Accessibility.ToCodeString()} sealed partial{(generatable.IsRecord ? " record" : "")} class {typeName} : - {Constants.WrapperValueObjectTypeName}<{underlyingTypeFullyQualifiedName}>, + WrapperValueObject<{underlyingTypeFullyQualifiedName}>, IValueWrapper<{typeName}, {underlyingTypeFullyQualifiedName}>, IEquatable<{typeName}>, {(isComparable ? "" : "//")}IComparable<{typeName}>, @@ -450,7 +450,7 @@ namespace {containingNamespace} {(isSpanParsable ? "" : "//")}ISpanParsable<{typeName}>, ISpanParsable{formattableParsableWrapperSuffix}, {(isUtf8SpanFormattable ? "" : "//")}IUtf8SpanFormattable, IUtf8SpanFormattable{formattableParsableWrapperSuffix}, {(isUtf8SpanParsable ? "" : "//")}IUtf8SpanParsable<{typeName}>, IUtf8SpanParsable{formattableParsableWrapperSuffix}, - {Constants.SerializableDomainObjectInterfaceTypeName}<{typeName}, {underlyingTypeFullyQualifiedName}> + ISerializableDomainObject<{typeName}, {underlyingTypeFullyQualifiedName}> {{ {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.StringComparison) ? "/*" : "")} {(generatable.UnderlyingTypeIsString ? "" : @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");")} @@ -487,7 +487,7 @@ namespace {containingNamespace} /// /// Serializes a domain object as a plain value. /// - {underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} {Constants.SerializableDomainObjectInterfaceTypeName}<{typeName}, {underlyingTypeFullyQualifiedName}>.Serialize() + {underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} ISerializableDomainObject<{typeName}, {underlyingTypeFullyQualifiedName}>.Serialize() {{ return this.Value; }} @@ -501,7 +501,7 @@ namespace {containingNamespace} /// /// Deserializes a plain value back into a domain object, without using a parameterized constructor. /// - static {typeName} {Constants.SerializableDomainObjectInterfaceTypeName}<{typeName}, {underlyingTypeFullyQualifiedName}>.Deserialize({underlyingTypeFullyQualifiedName} value) + static {typeName} ISerializableDomainObject<{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) diff --git a/DomainModeling.Tests/DomainModeling.Tests.csproj b/DomainModeling.Tests/DomainModeling.Tests.csproj index 1557932..cc4fdd9 100644 --- a/DomainModeling.Tests/DomainModeling.Tests.csproj +++ b/DomainModeling.Tests/DomainModeling.Tests.csproj @@ -23,11 +23,11 @@ - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index c87f6c2..f421729 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -11,6 +11,7 @@ 13 True True + README.md @@ -97,10 +98,8 @@ Misc improvements - - True - - + + @@ -108,17 +107,10 @@ Misc improvements - - false - Content - PreserveNewest - - - - - - - + + + + From 6e09698dbb32632e1fe92ca84d7ab9d7c5b64a0f Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 3 Sep 2025 23:21:59 +0200 Subject: [PATCH 09/23] Added EnumerableComparer overloads that avoid boxing for ImmutableAray. (ArraySegment, the other common collection struct, has implicit conversions from arrays, which mess with nullability constraints.) --- .../Comparisons/EnumerableComparer.cs | 112 ++++++++++++------ 1 file changed, 76 insertions(+), 36 deletions(-) diff --git a/DomainModeling/Comparisons/EnumerableComparer.cs b/DomainModeling/Comparisons/EnumerableComparer.cs index d874c24..19d0c16 100644 --- a/DomainModeling/Comparisons/EnumerableComparer.cs +++ b/DomainModeling/Comparisons/EnumerableComparer.cs @@ -1,5 +1,7 @@ using System.Collections; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; namespace Architect.DomainModeling.Comparisons; @@ -25,6 +27,20 @@ public static int GetEnumerableHashCode([AllowNull] IEnumerable enumerable) return -1; } + /// + /// + /// Returns a hash code over some of the content of the given . + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int GetEnumerableHashCode([AllowNull] ImmutableArray? enumerable) + { + if (enumerable is not {} value) return 0; + var span = value.AsSpan(); + if (span.Length == 0) return 1; + return HashCode.Combine(span.Length, span[0], span[^1]); + } + /// /// /// Returns a hash code over some of the content of the given . @@ -66,6 +82,59 @@ public static int GetEnumerableHashCode([AllowNull] IEnumerable + /// + /// Compares the given objects for equality by comparing their elements. + /// + /// + /// This method performs equality checks on the 's elements. + /// It is not recursive. To support nested collections, use custom collections that override their equality checks accordingly. + /// + /// + /// This non-generic overload should be avoided if possible. + /// It lacks the ability to special-case generic types, which may lead to unexpected results. + /// For example, two instances with an ignore-case comparer may consider each other equal despite having different-cased contents. + /// However, the current method has no knowledge of their comparers or their order-agnosticism, and may return a different result. + /// + /// + /// Unlike , this method may cause boxing of elements that are of a value type. + /// + /// + public static bool EnumerableEquals([AllowNull] IEnumerable left, [AllowNull] IEnumerable right) + { + if (ReferenceEquals(left, right)) return true; + if (left is null || right is null) return false; // Double nulls are already handled above + + var rightEnumerator = right.GetEnumerator(); + using (rightEnumerator as IDisposable) + { + foreach (var leftElement in left) + if (!rightEnumerator.MoveNext() || !Equals(leftElement, rightEnumerator.Current)) + return false; + if (rightEnumerator.MoveNext()) return false; + } + + return true; + } + + /// + /// + /// Compares the given objects for equality by comparing their elements. + /// + /// + /// This method performs equality checks on the 's elements. + /// It is not recursive. To support nested collections, use custom collections that override their equality checks accordingly. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool EnumerableEquals([AllowNull] ImmutableArray? left, [AllowNull] ImmutableArray? right) + { + if (left is not ImmutableArray leftValue || right is not ImmutableArray rightValue) + return left is null & right is null; + + return MemoryExtensions.SequenceEqual(leftValue.AsSpan(), rightValue.AsSpan()); + } + /// /// /// Compares the given objects for equality by comparing their elements. @@ -89,7 +158,7 @@ public static bool EnumerableEquals([AllowNull] IEnumerable 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) + if (left is ImmutableArray leftImmutableArray && right is ImmutableArray rightImmutableArray) return MemoryExtensions.SequenceEqual(leftImmutableArray.AsSpan(), rightImmutableArray.AsSpan()); // Prefer to index directly, to avoid allocation of an enumerator @@ -149,41 +218,6 @@ static bool GenericEnumerableEquals(IEnumerable leftEnumerable, IEnume } } - /// - /// - /// Compares the given objects for equality by comparing their elements. - /// - /// - /// This method performs equality checks on the 's elements. - /// It is not recursive. To support nested collections, use custom collections that override their equality checks accordingly. - /// - /// - /// This non-generic overload should be avoided if possible. - /// It lacks the ability to special-case generic types, which may lead to unexpected results. - /// For example, two instances with an ignore-case comparer may consider each other equal despite having different-cased contents. - /// However, the current method has no knowledge of their comparers or their order-agnosticism, and may return a different result. - /// - /// - /// Unlike , this method may cause boxing of elements that are of a value type. - /// - /// - public static bool EnumerableEquals([AllowNull] IEnumerable left, [AllowNull] IEnumerable right) - { - if (ReferenceEquals(left, right)) return true; - if (left is null || right is null) return false; // Double nulls are already handled above - - var rightEnumerator = right.GetEnumerator(); - using (rightEnumerator as IDisposable) - { - foreach (var leftElement in left) - if (!rightEnumerator.MoveNext() || !Equals(leftElement, rightEnumerator.Current)) - return false; - if (rightEnumerator.MoveNext()) return false; - } - - return true; - } - /// /// /// Returns a hash code over some of the content of the given wrapped in a . @@ -192,6 +226,7 @@ public static bool EnumerableEquals([AllowNull] IEnumerable left, [AllowNull] IE /// For a corresponding equality check, use . /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int GetMemoryHashCode(Memory? memory) { return GetMemoryHashCode((ReadOnlyMemory?)memory); @@ -205,6 +240,7 @@ public static int GetMemoryHashCode(Memory? memory) /// For a corresponding equality check, use . /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int GetMemoryHashCode(ReadOnlyMemory? memory) { if (memory is null) return 0; @@ -219,6 +255,7 @@ public static int GetMemoryHashCode(ReadOnlyMemory? memory) /// For a corresponding equality check, use . /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int GetMemoryHashCode(Memory memory) { return GetMemoryHashCode((ReadOnlyMemory)memory); @@ -232,6 +269,7 @@ public static int GetMemoryHashCode(Memory memory) /// For a corresponding equality check, use . /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int GetMemoryHashCode(ReadOnlyMemory memory) { return GetSpanHashCode(memory.Span); @@ -245,6 +283,7 @@ public static int GetMemoryHashCode(ReadOnlyMemory memory) /// For a corresponding equality check, use . /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int GetSpanHashCode(Span span) { return GetSpanHashCode((ReadOnlySpan)span); @@ -258,6 +297,7 @@ public static int GetSpanHashCode(Span span) /// For a corresponding equality check, use . /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int GetSpanHashCode(ReadOnlySpan span) { // Note that we do not distinguish between a default span and a regular empty span From 6bfe159947ff5b9910298c01f9a5daee8cf02268 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Sat, 6 Sep 2025 01:23:15 +0200 Subject: [PATCH 10/23] Added serialization to/from deepest underlying type (recursive). Also cleaned up the way in which lines not being source generated are outcommented. Also improved source generator performance. Also prevented transient issues when reporting warnings on wrapper types. --- ...inModelConfiguratorGenerator.Identities.cs | 15 +- ...nfiguratorGenerator.WrapperValueObjects.cs | 15 +- .../EntityFrameworkConfigurationGenerator.cs | 60 ++- ...ns.cs => DiagnosticReportingExtensions.cs} | 2 +- DomainModeling.Generator/EnumExtensions.cs | 24 +- DomainModeling.Generator/IdentityGenerator.cs | 382 +++++++------- DomainModeling.Generator/SymbolExtensions.cs | 15 +- .../TypeSymbolExtensions.cs | 8 +- .../ValueObjectGenerator.cs | 50 +- .../ValueWrapperGenerator.cs | 79 ++- .../WrapperValueObjectGenerator.cs | 489 +++++++++--------- ...ityFrameworkConfigurationGeneratorTests.cs | 78 ++- DomainModeling.Tests/IdentityTests.cs | 138 ++++- .../WrapperValueObjectTests.cs | 146 +++++- .../Configuration/IDomainEventConfigurator.cs | 3 +- .../Configuration/IEntityConfigurator.cs | 3 +- .../Configuration/IIdentityConfigurator.cs | 9 +- .../IWrapperValueObjectConfigurator.cs | 9 +- .../Conversions/DomainObjectSerializer.cs | 20 +- DomainModeling/Conversions/IValueWrapper.cs | 38 -- .../Conversions/ObjectInstantiator.cs | 4 + .../ValueWrapperFormattingExtensions.cs | 1 + .../Conversions/ValueWrapperJsonConverter.cs | 4 +- .../ValueWrapperNewtonsoftJsonConverter.cs | 4 +- .../ValueWrapperParsingExtensions.cs | 1 + .../Conversions/ValueWrapperUnwrapper.cs | 49 ++ DomainModeling/DomainModeling.csproj | 19 +- DomainModeling/ISerializableDomainObject.cs | 1 + DomainModeling/IValueWrapper.cs | 109 ++++ 29 files changed, 1173 insertions(+), 602 deletions(-) rename DomainModeling.Generator/{SourceProductionContextExtensions.cs => DiagnosticReportingExtensions.cs} (96%) delete mode 100644 DomainModeling/Conversions/IValueWrapper.cs create mode 100644 DomainModeling/Conversions/ValueWrapperUnwrapper.cs create mode 100644 DomainModeling/IValueWrapper.cs diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs index 8c1bc27..a8ce5c0 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs @@ -7,19 +7,22 @@ public partial class DomainModelConfiguratorGenerator { internal static void GenerateSourceForIdentities( SourceProductionContext context, - (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input) + (ImmutableArray ValueWrappers, (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) + // Generate the method only if we have any value wrappers, or if we are an assembly in which ConfigureConventions() is called + if (!input.ValueWrappers.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.TypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new Architect.DomainModeling.Configuration.IIdentityConfigurator.Args()); - """)); + var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.ValueWrappers + .Where(generatable => generatable.IsIdentity) + .Select(generatable => (Generatable: generatable, CoreTypeName: ValueWrapperGenerator.GetCoreTypeFullyQualifiedName(input.ValueWrappers, generatable.TypeName, generatable.ContainingNamespace))) + .Select(tuple => $$""" + configurator.ConfigureIdentity<{{tuple.Generatable.ContainingNamespace}}.{{tuple.Generatable.TypeName}}, {{tuple.Generatable.UnderlyingTypeFullyQualifiedName}}, {{tuple.CoreTypeName}}>({{Environment.NewLine}} new Architect.DomainModeling.Configuration.IIdentityConfigurator.Args()); + """)); var source = $@" using Architect.DomainModeling; diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs index 32cd6a9..98d56bd 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs @@ -7,19 +7,22 @@ public partial class DomainModelConfiguratorGenerator { internal static void GenerateSourceForWrapperValueObjects( SourceProductionContext context, - (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input) + (ImmutableArray ValueWrappers, (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) + // Generate the method only if we have any value wrappers, or if we are an assembly in which ConfigureConventions() is called + if (!input.ValueWrappers.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 Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator.Args()); - """)); + var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.ValueWrappers + .Where(generatable => !generatable.IsIdentity) + .Select(generatable => (Generatable: generatable, CoreTypeName: ValueWrapperGenerator.GetCoreTypeFullyQualifiedName(input.ValueWrappers, generatable.TypeName, generatable.ContainingNamespace))) + .Select(tuple => $$""" + configurator.ConfigureWrapperValueObject<{{tuple.Generatable.ContainingNamespace}}.{{tuple.Generatable.TypeName}}, {{tuple.Generatable.UnderlyingTypeFullyQualifiedName}}, {{tuple.CoreTypeName}}>({{Environment.NewLine}} new Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator.Args()); + """)); var source = $@" using Architect.DomainModeling; diff --git a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs index e4b592d..5987b80 100644 --- a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs +++ b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs @@ -259,34 +259,36 @@ file sealed record class DomainModelConfigurator( file sealed record class EntityFrameworkIdentityConfigurator(ModelConfigurationBuilder ConfigurationBuilder) : Architect.DomainModeling.Configuration.IIdentityConfigurator {{ - public void ConfigureIdentity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, TUnderlying>( - in Architect.DomainModeling.Configuration.IIdentityConfigurator.Args _) - where TIdentity : IIdentity, ISerializableDomainObject + private static readonly ConverterMappingHints DecimalIdConverterMappingHints = new ConverterMappingHints(precision: 28, scale: 0); // For decimal IDs + + public void ConfigureIdentity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, TUnderlying, TCore>( + in Architect.DomainModeling.Configuration.IIdentityConfigurator.Args args) + where TIdentity : IIdentity, IDirectValueWrapper, ICoreValueWrapper where TUnderlying : notnull, IEquatable, IComparable {{ // Configure properties of the type this.ConfigurationBuilder.Properties() - .HaveConversion>(); + .HaveConversion>(); // Configure non-property occurrences of the type, such as in CAST(), SUM(), AVG(), etc. this.ConfigurationBuilder.DefaultTypeMapping() - .HasConversion>(); + .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)) + if (typeof(TCore) == typeof(decimal)) this.ConfigurationBuilder.DefaultTypeMapping() .HasPrecision(28, 0); }} private sealed class IdentityValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> : ValueConverter - where TModel : ISerializableDomainObject + where TModel : IValueWrapper {{ public IdentityValueObjectConverter() : base( - DomainObjectSerializer.CreateSerializeExpression(), - DomainObjectSerializer.CreateDeserializeExpression(), - new ConverterMappingHints(precision: 28, scale: 0)) // For decimal IDs + model => DomainObjectSerializer.Serialize(model)!, + provider => DomainObjectSerializer.Deserialize(provider)!, + typeof(TProvider) == typeof(decimal) ? DecimalIdConverterMappingHints : null) {{ }} }} @@ -296,28 +298,28 @@ file sealed record class EntityFrameworkWrapperValueObjectConfigurator( ModelConfigurationBuilder ConfigurationBuilder) : Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator {{ - public void ConfigureWrapperValueObject<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue>( - in Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator.Args _) - where TWrapper : IWrapperValueObject, ISerializableDomainObject + public void ConfigureWrapperValueObject<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue, TCore>( + in Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator.Args args) + where TWrapper : IWrapperValueObject, IDirectValueWrapper, ICoreValueWrapper where TValue : notnull {{ // Configure properties of the type this.ConfigurationBuilder.Properties() - .HaveConversion>(); + .HaveConversion>(); // Configure non-property occurrences of the type, such as in CAST(), SUM(), AVG(), etc. this.ConfigurationBuilder.DefaultTypeMapping() - .HasConversion>(); + .HasConversion>(); }} private sealed class WrapperValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> : ValueConverter - where TModel : ISerializableDomainObject + where TModel : IValueWrapper {{ public WrapperValueObjectConverter() : base( - DomainObjectSerializer.CreateSerializeExpression(), - DomainObjectSerializer.CreateDeserializeExpression()) + model => DomainObjectSerializer.Serialize(model)!, + provider => DomainObjectSerializer.Deserialize(provider)!) {{ }} }} @@ -350,7 +352,7 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven #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))); + entityType.ConstructorBinding = UninitializedInstantiationBinding.Create(() => DomainObjectSerializer.Deserialize()); #pragma warning restore EF1001 // Internal EF Core API usage }} @@ -363,7 +365,7 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven #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))); + entityType.ConstructorBinding = UninitializedInstantiationBinding.Create(() => DomainObjectSerializer.Deserialize()); #pragma warning restore EF1001 // Internal EF Core API usage }} @@ -373,7 +375,13 @@ private sealed class UninitializedInstantiationBinding private static readonly MethodInfo GetUninitializedObjectMethod = typeof(RuntimeHelpers).GetMethod(nameof(RuntimeHelpers.GetUninitializedObject))!; public override Type RuntimeType {{ get; }} - private Expression? Expression {{ get; }} + private Expression Expression {{ get; }} + + public static UninitializedInstantiationBinding Create<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] T>( + Expression> expression) + {{ + return new UninitializedInstantiationBinding(typeof(T), expression.Body); + }} public UninitializedInstantiationBinding( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type runtimeType, @@ -381,15 +389,15 @@ public UninitializedInstantiationBinding( : base(Array.Empty()) {{ this.RuntimeType = runtimeType; - this.Expression = expression; + this.Expression = expression ?? + Expression.Convert( + Expression.Call(method: GetUninitializedObjectMethod, arguments: Expression.Constant(this.RuntimeType)), + this.RuntimeType); }} public override Expression CreateConstructorExpression(ParameterBindingInfo bindingInfo) {{ - return this.Expression ?? - Expression.Convert( - Expression.Call(method: GetUninitializedObjectMethod, arguments: Expression.Constant(this.RuntimeType)), - this.RuntimeType); + return this.Expression; }} public override InstantiationBinding With(IReadOnlyList parameterBindings) diff --git a/DomainModeling.Generator/SourceProductionContextExtensions.cs b/DomainModeling.Generator/DiagnosticReportingExtensions.cs similarity index 96% rename from DomainModeling.Generator/SourceProductionContextExtensions.cs rename to DomainModeling.Generator/DiagnosticReportingExtensions.cs index 58748b7..08dc26d 100644 --- a/DomainModeling.Generator/SourceProductionContextExtensions.cs +++ b/DomainModeling.Generator/DiagnosticReportingExtensions.cs @@ -6,7 +6,7 @@ namespace Architect.DomainModeling.Generator; /// /// Defines extension methods on . /// -internal static class SourceProductionContextExtensions +internal static class DiagnosticReportingExtensions { /// /// Shorthand extension method to report a diagnostic, with less boilerplate code. diff --git a/DomainModeling.Generator/EnumExtensions.cs b/DomainModeling.Generator/EnumExtensions.cs index 8084ff4..6234962 100644 --- a/DomainModeling.Generator/EnumExtensions.cs +++ b/DomainModeling.Generator/EnumExtensions.cs @@ -59,10 +59,14 @@ public static string ToCodeString(this Accessibility accessibility, string unspe /// myEnum |= MyEnum.SomeFlag.If(1 == 2); /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static T If(this T enumValue, bool condition) where T : unmanaged, Enum { - return condition ? enumValue : default; + // Branch-free implementation + ReadOnlySpan values = stackalloc T[] { default, enumValue, }; + var index = Unsafe.As(ref condition); + return values[index]; } /// @@ -77,15 +81,20 @@ public static T If(this T enumValue, bool condition) /// myEnum |= MyEnum.SomeFlag.Unless(1 == 2); /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static T Unless(this T enumValue, bool condition) where T : unmanaged, Enum { - return condition ? default : enumValue; + // Branch-free implementation + ReadOnlySpan values = stackalloc T[] { enumValue, default, }; + var index = Unsafe.As(ref condition); + return values[index]; } /// - /// Efficiently returns whether the has the given set. + /// Efficiently returns whether the has the given (s) set. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool HasFlags(this T subject, T flag) where T : unmanaged, Enum { @@ -107,11 +116,8 @@ public static bool HasFlags(this T subject, T flag) private static ulong GetNumericValue(T enumValue) where T : unmanaged, Enum { - Span ulongSpan = stackalloc ulong[] { 0UL }; - var span = MemoryMarshal.Cast(ulongSpan); - - span[0] = enumValue; - - return ulongSpan[0]; + var result = 0UL; + Unsafe.WriteUnaligned(ref Unsafe.As(ref result), enumValue); + return result; } } diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index ec72779..eaf1ac0 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using Architect.DomainModeling.Generator.Common; using Architect.DomainModeling.Generator.Configurators; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -29,9 +28,11 @@ internal void InitializeBasicProvider(IncrementalGeneratorInitializationContext INamedTypeSymbol type when LooksLikeEntity(type) && IsEntity(type, out var entityInterface) && entityInterface.TypeArguments[0].TypeKind == TypeKind.Error && entityInterface.TypeArguments[1] is ITypeSymbol underlyingType => new ValueWrapperGenerator.BasicGeneratable( + isIdentity: true, typeName: entityInterface.TypeArguments[0].Name, containingNamespace: type.ContainingNamespace.ToString(), underlyingTypeFullyQualifiedName: underlyingType.ToString(), + customCoreTypeFullyQualifiedName: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[1].ToString(), isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => @@ -41,18 +42,22 @@ INamedTypeSymbol type when LooksLikeEntity(type) && IsEntity(type, out var entit isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attribute.AttributeClass!.TypeArguments[0] is ITypeSymbol underlyingType => - new ValueWrapperGenerator.BasicGeneratable( - typeName: type.Name, - containingNamespace: type.ContainingNamespace.ToString(), - underlyingTypeFullyQualifiedName: underlyingType.ToString(), - isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), - isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }), - isUtf8SpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), - isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), + GetFirstProblem((TypeDeclarationSyntax)context.Node, type, underlyingType) is { } + ? default + : new ValueWrapperGenerator.BasicGeneratable( + isIdentity: true, + typeName: type.Name, + containingNamespace: type.ContainingNamespace.ToString(), + underlyingTypeFullyQualifiedName: underlyingType.ToString(), + customCoreTypeFullyQualifiedName: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[1].ToString(), + isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), + isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }), + isUtf8SpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), + isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), _ => default, }) .Where(generatable => generatable != default) @@ -64,8 +69,8 @@ INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attr /// Additionally gathers detailed info per individual identity. /// Generates source based on all of the above. /// - internal void Generate(IncrementalGeneratorInitializationContext context, - IncrementalValueProvider> identities, + internal void Generate( + IncrementalGeneratorInitializationContext context, IncrementalValueProvider> valueWrappers) { var provider = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, TransformSyntaxNode) @@ -74,7 +79,7 @@ internal void Generate(IncrementalGeneratorInitializationContext context, context.RegisterSourceOutput(provider.Combine(valueWrappers), GenerateSource!); - var aggregatedProvider = identities.Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + var aggregatedProvider = valueWrappers.Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForIdentities); } @@ -99,6 +104,66 @@ private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeDat return attribute != null; } + private static Diagnostic? GetFirstProblem(TypeDeclarationSyntax tds, INamedTypeSymbol type, ITypeSymbol underlyingType) + { + var isPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); + + // Require the expected inheritance + if (!isPartial && !type.IsOrImplementsInterface(interf => interf.IsType("IIdentity", "Architect", "DomainModeling", arity: 1), out _)) + return CreateDiagnostic("IdentityGeneratorUnexpectedInheritance", "Unexpected interface", + "Type marked as identity value object lacks IIdentity interface. Did you forget the 'partial' keyword and elude source generation?", DiagnosticSeverity.Warning); + + // Require IDirectValueWrapper + var hasDirectValueWrapperInterface = type.AllInterfaces.Any(interf => + interf.IsType("IDirectValueWrapper", "Architect", "DomainModeling", arity: 2) && !interf.IsImplicitlyDeclared && + interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default) && interf.TypeArguments[1].Equals(underlyingType, SymbolEqualityComparer.Default)); + if (!isPartial && !hasDirectValueWrapperInterface) + return CreateDiagnostic("IdentityGeneratorMissingDirectValueWrapper", "Missing interface", + $"Type marked as identity value object lacks IDirectValueWrapper<{type.Name}, {underlyingType.Name}> interface.", DiagnosticSeverity.Warning); + + // Require ICoreValueWrapper + var hasCoreValueWrapperInterface = type.AllInterfaces.Any(interf => + interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2) && !interf.IsImplicitlyDeclared && + interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default)); + if (!isPartial && !hasCoreValueWrapperInterface) + return CreateDiagnostic("IdentityGeneratorMissingCoreValueWrapper", "Missing interface", + $"Type marked as identity value object lacks ICoreValueWrapper<{type.Name}, {underlyingType.Name}> interface.", DiagnosticSeverity.Warning); + + // No source generation, only above analyzers + if (isPartial) + { + // Only if struct + if (type.TypeKind != TypeKind.Struct) + return CreateDiagnostic("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); + + // Only if non-abstract + if (type.IsAbstract) + return CreateDiagnostic("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); + + // Only if non-generic + if (type.IsGeneric()) + return CreateDiagnostic("IdentityGeneratorGenericType", "Source-generated generic type", + "The type was not source-generated because it is generic. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning); + + // Only if non-nested + if (type.IsNested()) + return CreateDiagnostic("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. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning); + } + + return null; + + // Local shorthand to create a diagnostic + Diagnostic CreateDiagnostic(string id, string title, string description, DiagnosticSeverity severity) + { + return Diagnostic.Create( + new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true), + type.Locations.FirstOrDefault()); + } + } + private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) { // Struct or class or record @@ -149,13 +214,18 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella var idType = entityInterface.TypeArguments[0]; underlyingType = entityInterface.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; if (result.IdTypeExists) + { + // Entity was needlessly used, with a preexisting TId + result.Problem = Diagnostic.Create(new DiagnosticDescriptor("EntityIdentityTypeAlreadyExists", "Entity identity type already exists", "Architect.DomainModeling", + "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, isEnabledByDefault: true), type.Locations.FirstOrDefault()); return result; + } result.IsStruct = true; result.ContainingNamespace = type.ContainingNamespace.ToString(); @@ -175,9 +245,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella underlyingType = attribute.AttributeClass!.TypeArguments[0]; result.IdTypeExists = true; - result.IdTypeLocation = type.Locations.FirstOrDefault(); result.IsIIdentity = type.IsOrImplementsInterface(interf => interf.IsType("IIdentity", "Architect", "DomainModeling", arity: 1), out _); - result.IsSerializableDomainObject = type.IsOrImplementsInterface(type => type.IsType("ISerializableDomainObject", "Architect", "DomainModeling", arity: 2), out _); result.IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); result.IsRecord = type.IsRecord; result.IsStruct = type.TypeKind == TypeKind.Struct; @@ -202,98 +270,90 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // Records override this, but our implementation is superior existingComponents |= IdTypeComponents.ToStringOverride.If(members.Any(member => - member.Name == nameof(ToString) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Arity == 0 && method.Parameters.Length == 0)); + member is IMethodSymbol { Name: nameof(ToString), IsImplicitlyDeclared: false, IsOverride: true, Arity: 0, Parameters.Length: 0, })); // Records override this, but our implementation is superior existingComponents |= IdTypeComponents.GetHashCodeOverride.If(members.Any(member => - member.Name == nameof(GetHashCode) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Arity == 0 && method.Parameters.Length == 0)); + member is IMethodSymbol { Name: nameof(GetHashCode), IsImplicitlyDeclared: false, IsOverride: true, Arity: 0, 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.Arity == 0 && method.Parameters.Length == 1 && + member is IMethodSymbol { Name: nameof(Equals), IsOverride: true, Arity: 0, Parameters.Length: 1, } method && method.Parameters[0].Type.SpecialType == SpecialType.System_Object)); // Records override this, but our implementation is superior existingComponents |= IdTypeComponents.EqualsMethod.If(members.Any(member => - member.Name == nameof(Equals) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Arity == 0 && method.Parameters.Length == 1 && + member.HasNameOrExplicitInterfaceImplementationName(nameof(Equals)) && member is IMethodSymbol { IsImplicitlyDeclared: false, IsOverride: false, Arity: 0, Parameters.Length: 1, } method && 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.Arity == 0 && method.Parameters.Length == 1 && + member.HasNameOrExplicitInterfaceImplementationName(nameof(IComparable.CompareTo)) && member is IMethodSymbol { Arity: 0, Parameters.Length: 1, } method && 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 is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_Equality") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.EqualityOperatorName, IsStatic: true, Parameters.Length: 2, } method && 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 is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_Inequality") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.InequalityOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.GreaterThanOperator.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_GreaterThan") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.LessThanOperator.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_LessThan") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.GreaterEqualsOperator.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_GreaterThanOrEqual") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.LessEqualsOperator.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_LessThanOrEqual") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.ConvertToOperator.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && - (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && method.ReturnType.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.ConvertFromOperator.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && - (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default) && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.NullableConvertToOperator.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && - (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && method.ReturnType.IsNullableOf(type) && (underlyingType.IsReferenceType ? method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default) : method.Parameters[0].Type.IsNullableOf(underlyingType)))); existingComponents |= IdTypeComponents.NullableConvertFromOperator.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && - (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && (underlyingType.IsReferenceType ? method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default) : method.ReturnType.IsNullableOf(underlyingType) && method.Parameters[0].Type.IsNullableOf(type)))); existingComponents |= IdTypeComponents.SerializeToUnderlying.If(members.Any(member => - member.HasNameOrExplicitInterfaceImplementationName("Serialize") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 0)); + member.HasNameOrExplicitInterfaceImplementationName("Serialize") && member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 0, } method && + method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.DeserializeFromUnderlying.If(members.Any(member => - member.HasNameOrExplicitInterfaceImplementationName("Deserialize") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && - method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); + member.HasNameOrExplicitInterfaceImplementationName("Deserialize") && member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 1, } method && + method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default) && + method.ReturnType.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.SystemTextJsonConverter.If(type.GetAttributes().Any(attribute => attribute.AttributeClass?.IsTypeWithNamespace("JsonConverterAttribute", "System.Text.Json.Serialization") == true)); @@ -305,21 +365,21 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella member.Name == "StringComparison")); existingComponents |= IdTypeComponents.FormattableToStringOverride.If(members.Any(member => - member.Name == nameof(IFormattable.ToString) && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member.HasNameOrExplicitInterfaceImplementationName("ToString") && member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 2, } method && method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.ParsableTryParseMethod.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 3, } method && member.HasNameOrExplicitInterfaceImplementationName("TryParse") && method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); existingComponents |= IdTypeComponents.ParsableParseMethod.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 2, } method && member.HasNameOrExplicitInterfaceImplementationName("Parse") && method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.SpanFormattableTryFormatMethod.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && + member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 4, } method && member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && method.Parameters[0].Type.IsSpanOfSpecialType(SpecialType.System_Char) && method.Parameters[1].Type.SpecialType == SpecialType.System_Int32 && method.Parameters[1].RefKind == RefKind.Out && @@ -327,20 +387,20 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella method.Parameters[3].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.SpanParsableTryParseMethod.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 3, } method && member.HasNameOrExplicitInterfaceImplementationName("TryParse") && method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); existingComponents |= IdTypeComponents.SpanParsableParseMethod.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 2, } method && member.HasNameOrExplicitInterfaceImplementationName("Parse") && method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.Utf8SpanFormattableTryFormatMethod.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && + member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 4, } method && member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && method.Parameters[0].Type.IsSpanOfSpecialType(SpecialType.System_Byte) && method.Parameters[1].Type.SpecialType == SpecialType.System_Int32 && method.Parameters[1].RefKind == RefKind.Out && @@ -348,24 +408,35 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella method.Parameters[3].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.Utf8SpanParsableTryParseMethod.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 3, } method && member.HasNameOrExplicitInterfaceImplementationName("TryParse") && method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Byte) && method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); existingComponents |= IdTypeComponents.Utf8SpanParsableParseMethod.If(members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 2, } method && member.HasNameOrExplicitInterfaceImplementationName("Parse") && method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Byte) && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= IdTypeComponents.CreateMethod.If(members.Any(member => - member is IMethodSymbol method && method.IsStatic && method.Arity == 0 && method.Parameters.Length == 1 && + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 1, } method && member.HasNameOrExplicitInterfaceImplementationName("Create") && - method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); + method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default) && + method.ReturnType.Equals(type, SymbolEqualityComparer.Default))); + + existingComponents |= IdTypeComponents.DirectValueWrapperInterface.If(type.AllInterfaces.Any(interf => + interf.IsType("IDirectValueWrapper", "Architect", "DomainModeling", arity: 2) && !interf.IsImplicitlyDeclared && + interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default) && interf.TypeArguments[1].Equals(underlyingType, SymbolEqualityComparer.Default))); + + existingComponents |= IdTypeComponents.CoreValueWrapperInterface.If(type.AllInterfaces.Any(interf => + interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2) && !interf.IsImplicitlyDeclared && + interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default))); result.ExistingComponents = existingComponents; + + result.Problem = GetFirstProblem(tds, type, underlyingType); } result.ToStringExpression = underlyingType.CreateValueToStringExpression(); @@ -383,7 +454,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return result; } - + private static void GenerateSource(SourceProductionContext context, (Generatable Generatable, ImmutableArray ValueWrappers) input) { context.CancellationToken.ThrowIfCancellationRequested(); @@ -391,6 +462,12 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var generatable = input.Generatable; var valueWrappers = input.ValueWrappers; + if (generatable.Problem is not null) + context.ReportDiagnostic(generatable.Problem); + + if (generatable.Problem is not null || (!generatable.IsPartial && generatable.IdTypeExists)) + return; + var containingNamespace = generatable.ContainingNamespace; var idTypeName = generatable.IdTypeName; var underlyingTypeFullyQualifiedName = generatable.UnderlyingTypeFullyQualifiedName; @@ -409,72 +486,10 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var existingComponents = generatable.ExistingComponents; var hasIdentityValueObjectAttribute = generatable.IdTypeExists; - (var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( - valueWrappers, - typeName: idTypeName, containingNamespace: containingNamespace, underlyingTypeFullyQualifiedName: underlyingTypeFullyQualifiedName); + var coreTypeFullyQualifiedName = ValueWrapperGenerator.GetCoreTypeFullyQualifiedName(valueWrappers, idTypeName, containingNamespace); - if (generatable.IdTypeExists) - { - // Entity was needlessly used, with a preexisting TId - 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, generatable.EntityTypeLocation); - return; - } - - // Require the expected inheritance - if (!generatable.IsPartial && !generatable.IsIIdentity) - { - context.ReportDiagnostic("IdentityGeneratorUnexpectedInheritance", "Unexpected interface", - "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. 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. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.IdTypeLocation); - return; - } - } + (var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( + valueWrappers, typeName: idTypeName, containingNamespace: containingNamespace); var summary = entityTypeName is null ? null : $@" /// @@ -502,6 +517,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Architect.DomainModeling; using Architect.DomainModeling.Conversions; @@ -511,25 +527,19 @@ namespace {containingNamespace} {{ {summary} - {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} - {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} - {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} - + {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} + {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} {(hasIdentityValueObjectAttribute ? "" : $"[IdentityValueObject<{underlyingTypeFullyQualifiedName}>]")} {(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} : IIdentity<{underlyingTypeFullyQualifiedName}>, - IValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}>, {(isSpanFormattable ? "" : "//")}ISpanFormattable, ISpanFormattable{formattableParsableWrapperSuffix}, {(isSpanParsable ? "" : "//")}ISpanParsable<{idTypeName}>, ISpanParsable{formattableParsableWrapperSuffix}, {(isUtf8SpanFormattable ? "" : "//")}IUtf8SpanFormattable, IUtf8SpanFormattable{formattableParsableWrapperSuffix}, {(isUtf8SpanParsable ? "" : "//")}IUtf8SpanParsable<{idTypeName}>, IUtf8SpanParsable{formattableParsableWrapperSuffix}, - ISerializableDomainObject<{idTypeName}, {underlyingTypeFullyQualifiedName}> + IDirectValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>, + ICoreValueWrapper<{idTypeName}, {coreTypeFullyQualifiedName}> {{ {(existingComponents.HasFlags(IdTypeComponents.Value) ? "/*" : "")} {nonNullStringSummary} @@ -545,6 +555,7 @@ namespace {containingNamespace} {(existingComponents.HasFlags(IdTypeComponents.Constructor) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.CreateMethod) ? "/*" : "")} + [MethodImpl(MethodImplOptions.AggressiveInlining)] static {idTypeName} IValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Create({underlyingTypeFullyQualifiedName} value) {{ return new {idTypeName}(value); @@ -555,7 +566,8 @@ namespace {containingNamespace} /// /// Serializes a domain object as a plain value. /// - {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")} ISerializableDomainObject<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Serialize() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")} IValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Serialize() {{ return this.Value; }} @@ -565,14 +577,48 @@ namespace {containingNamespace} /// /// Deserializes a plain value back into a domain object, without using a parameterized constructor. /// - static {idTypeName} ISerializableDomainObject<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Deserialize({underlyingTypeFullyQualifiedName} value) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static {idTypeName} IValueWrapper<{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 }}; + {(existingComponents.HasFlags(IdTypeComponents.UnsettableValue) ? "// To instead get safe syntax, make the Value property '{ get; private init; }' (or let the source generator implement it)" : "")} + {(existingComponents.HasFlags(IdTypeComponents.UnsettableValue) ? $"return System.Runtime.CompilerServices.Unsafe.As<{underlyingTypeFullyQualifiedName}, {idTypeName}>(ref value);" : "")} + {(existingComponents.HasFlags(IdTypeComponents.UnsettableValue) ? "//" : "")}return new {idTypeName}() {{ Value = value }}; }} {(existingComponents.HasFlags(IdTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} + {(generatable.ExistingComponents.HasFlags(IdTypeComponents.CoreValueWrapperInterface) ? "/* Core manually specified" : coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "/* For nested wrapper types only" : "")} + [MaybeNull] + {coreTypeFullyQualifiedName} IValueWrapper<{idTypeName}, {coreTypeFullyQualifiedName}>.Value => ValueWrapperUnwrapper.Unwrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(this.Value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static {idTypeName} IValueWrapper<{idTypeName}, {coreTypeFullyQualifiedName}>.Create({coreTypeFullyQualifiedName} value) + {{ + var intermediateValue = ValueWrapperUnwrapper.Wrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(value); + return ValueWrapperUnwrapper.Wrap<{idTypeName}, {underlyingTypeFullyQualifiedName}>(intermediateValue); + }} + + /// + /// Serializes a domain object as a plain value. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: MaybeNull] + {coreTypeFullyQualifiedName} IValueWrapper<{idTypeName}, {coreTypeFullyQualifiedName}>.Serialize() + {{ + return DomainObjectSerializer.Serialize<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>( + DomainObjectSerializer.Serialize<{idTypeName}, {underlyingTypeFullyQualifiedName}>(this)); + }} + + /// + /// Deserializes a plain value back into a domain object, without using a parameterized constructor. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static {idTypeName} IValueWrapper<{idTypeName}, {coreTypeFullyQualifiedName}>.Deserialize({coreTypeFullyQualifiedName} value) + {{ + var intermediateValue = DomainObjectSerializer.Deserialize<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(value); + return DomainObjectSerializer.Deserialize<{idTypeName}, {underlyingTypeFullyQualifiedName}>(intermediateValue); + }} + {(coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "*/" : generatable.ExistingComponents.HasFlags(IdTypeComponents.CoreValueWrapperInterface) ? "*/" : "")} + {(existingComponents.HasFlags(IdTypeComponents.StringComparison) ? "/*" : "")} {(isString ? @"private StringComparison StringComparison => StringComparison.Ordinal;" @@ -619,43 +665,21 @@ public int CompareTo({idTypeName} other) }} {(existingComponents.HasFlags(IdTypeComponents.CompareToMethod) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "/*" : "")} - public static bool operator ==({idTypeName} left, {idTypeName} right) => left.Equals(right); - {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.NotEqualsOperator) ? "/*" : "")} - public static bool operator !=({idTypeName} left, {idTypeName} right) => !(left == right); - {(existingComponents.HasFlags(IdTypeComponents.NotEqualsOperator) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.GreaterThanOperator) ? "/*" : "")} - public static bool operator >({idTypeName} left, {idTypeName} right) => left.CompareTo(right) > 0; - {(existingComponents.HasFlags(IdTypeComponents.GreaterThanOperator) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.LessThanOperator) ? "/*" : "")} - public static bool operator <({idTypeName} left, {idTypeName} right) => left.CompareTo(right) < 0; - {(existingComponents.HasFlags(IdTypeComponents.LessThanOperator) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.GreaterEqualsOperator) ? "/*" : "")} - public static bool operator >=({idTypeName} left, {idTypeName} right) => left.CompareTo(right) >= 0; - {(existingComponents.HasFlags(IdTypeComponents.GreaterEqualsOperator) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.LessEqualsOperator) ? "/*" : "")} - public static bool operator <=({idTypeName} left, {idTypeName} right) => left.CompareTo(right) <= 0; - {(existingComponents.HasFlags(IdTypeComponents.LessEqualsOperator) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.ConvertToOperator) ? "/*" : "")} - 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}{(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{(underlyingTypeIsStruct ? ".Value" : "")}); - {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "/*" : "")}{nonNullStringSummary} - {(underlyingTypeIsStruct || isNonNullString ? @"[return: NotNullIfNotNull(""id"")]" : "")} - public static implicit operator {underlyingTypeFullyQualifiedName}?({idTypeName}? id) => id?.Value; - {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "*/" : "")} + {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "//" : "")}public static bool operator ==({idTypeName} left, {idTypeName} right) => left.Equals(right); + {(existingComponents.HasFlags(IdTypeComponents.NotEqualsOperator) ? "//" : "")}public static bool operator !=({idTypeName} left, {idTypeName} right) => !(left == right); + + {(existingComponents.HasFlags(IdTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({idTypeName} left, {idTypeName} right) => left.CompareTo(right) > 0; + {(existingComponents.HasFlags(IdTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({idTypeName} left, {idTypeName} right) => left.CompareTo(right) < 0; + {(existingComponents.HasFlags(IdTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({idTypeName} left, {idTypeName} right) => left.CompareTo(right) >= 0; + {(existingComponents.HasFlags(IdTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({idTypeName} left, {idTypeName} right) => left.CompareTo(right) <= 0; + + {(existingComponents.HasFlags(IdTypeComponents.ConvertToOperator) ? "//" : "")}public static implicit operator {idTypeName}({underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct ? "" : "?")} value) => new {idTypeName}(value); + {(existingComponents.HasFlags(IdTypeComponents.ConvertFromOperator) ? "//" : "")}public static implicit operator {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")}({idTypeName} id) => id.Value; + + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "//" : "")}[return: NotNullIfNotNull(""value"")] + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "//" : "")}public static implicit operator {idTypeName}?({underlyingTypeFullyQualifiedName}? value) => value is null ? ({idTypeName}?)null : new {idTypeName}(value{(underlyingTypeIsStruct ? ".Value" : "")}); + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "//" : "")}{(underlyingTypeIsStruct || isNonNullString ? @"[return: NotNullIfNotNull(""id"")]" : "[return: MaybeNull]")} + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "//" : "")}public static implicit operator {underlyingTypeFullyQualifiedName}?({idTypeName}? id) => id?.Value; #region Formatting & Parsing @@ -760,6 +784,8 @@ internal enum IdTypeComponents : ulong Utf8SpanParsableTryParseMethod = 1UL << 31, Utf8SpanParsableParseMethod = 1UL << 32, CreateMethod = 1UL << 33, + DirectValueWrapperInterface = 1UL << 34, + CoreValueWrapperInterface = 1UL << 35, } private sealed record Generatable @@ -787,10 +813,8 @@ private sealed record Generatable 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 IdTypeComponents ExistingComponents { get; set; } - public SimpleLocation? EntityTypeLocation { get; set; } - public SimpleLocation? IdTypeLocation { get; set; } + public Diagnostic? Problem { get; set; } } } diff --git a/DomainModeling.Generator/SymbolExtensions.cs b/DomainModeling.Generator/SymbolExtensions.cs index 6c571b4..2da6033 100644 --- a/DomainModeling.Generator/SymbolExtensions.cs +++ b/DomainModeling.Generator/SymbolExtensions.cs @@ -15,14 +15,11 @@ public static bool HasNameOrExplicitInterfaceImplementationName(this ISymbol sym var index = haystack.LastIndexOf(needle); - if (index < 0) - return false; - - if (index == 0) - return true; - - var nameFollowsDot = haystack[index - 1] == '.'; - - return nameFollowsDot; + return index switch + { + < 0 => false, // Name not found + 0 => haystack.Length == needle.Length, // Starts with name, so depends on whether is exact match + _ => haystack[index - 1] == '.' && haystack.Length == index + needle.Length, // Contains name, so depends on whether name directly follows a dot and is suffix + }; } } diff --git a/DomainModeling.Generator/TypeSymbolExtensions.cs b/DomainModeling.Generator/TypeSymbolExtensions.cs index c12e068..d7ede56 100644 --- a/DomainModeling.Generator/TypeSymbolExtensions.cs +++ b/DomainModeling.Generator/TypeSymbolExtensions.cs @@ -11,8 +11,6 @@ internal static class TypeSymbolExtensions { private const string ComparisonsNamespace = "Architect.DomainModeling.Comparisons"; - private static IReadOnlyCollection ConversionOperatorNames { get; } = ["op_Implicit", "op_Explicit",]; - /// /// Returns the full CLR metadata name of the , e.g. "Namespace.Type+NestedGenericType`1". /// @@ -554,7 +552,7 @@ public static bool HasEqualsOverride(this ITypeSymbol typeSymbol) public static bool HasConversionTo(this ITypeSymbol typeSymbol, SpecialType specialType) { var result = typeSymbol.SpecialType != specialType && typeSymbol.GetMembers().Any(member => - member is IMethodSymbol method && ConversionOperatorNames.Contains(method.Name) && member.DeclaredAccessibility == Accessibility.Public && + member is IMethodSymbol { Name: WellKnownMemberNames.ExplicitConversionName or WellKnownMemberNames.ImplicitConversionName, DeclaredAccessibility: Accessibility.Public, } method && method.ReturnType.SpecialType == specialType); return result; } @@ -565,8 +563,8 @@ public static bool HasConversionTo(this ITypeSymbol typeSymbol, SpecialType spec public static bool HasConversionFrom(this ITypeSymbol typeSymbol, SpecialType specialType) { var result = typeSymbol.SpecialType != specialType && typeSymbol.GetMembers().Any(member => - member is IMethodSymbol method && ConversionOperatorNames.Contains(method.Name) && member.DeclaredAccessibility == Accessibility.Public && - method.Parameters.Length == 1 && method.Parameters[0].Type.SpecialType == specialType); + member is IMethodSymbol { Name: WellKnownMemberNames.ExplicitConversionName or WellKnownMemberNames.ImplicitConversionName, DeclaredAccessibility: Accessibility.Public, Parameters.Length: 1, } method && + method.Parameters[0].Type.SpecialType == specialType); return result; } diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index 471b112..2b04072 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -67,55 +67,55 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // Records override this, but our implementation is superior existingComponents |= ValueObjectTypeComponents.ToStringOverride.If(members.Any(member => - member.Name == nameof(ToString) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Parameters.Length == 0)); + member is IMethodSymbol { Name: nameof(ToString), IsImplicitlyDeclared: false, IsOverride: true, Arity: 0, Parameters.Length: 0, })); // Records override this, but our implementation is superior existingComponents |= ValueObjectTypeComponents.GetHashCodeOverride.If(members.Any(member => - member.Name == nameof(GetHashCode) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Parameters.Length == 0)); + member is IMethodSymbol { Name: nameof(GetHashCode), IsImplicitlyDeclared: false, IsOverride: true, Arity: 0, 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 && + member is IMethodSymbol { Name: nameof(Equals), IsOverride: true, Arity: 0, Parameters.Length: 1, } method && method.Parameters[0].Type.SpecialType == SpecialType.System_Object)); // Records override this, but our implementation is superior existingComponents |= ValueObjectTypeComponents.EqualsMethod.If(members.Any(member => - member.Name == nameof(Equals) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Parameters.Length == 1 && + member.HasNameOrExplicitInterfaceImplementationName(nameof(Equals)) && member is IMethodSymbol { IsImplicitlyDeclared: false, IsOverride: false, Arity: 0, Parameters.Length: 1, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= ValueObjectTypeComponents.CompareToMethod.If(members.Any(member => - member.Name == nameof(IComparable.CompareTo) && member is IMethodSymbol method && method.Parameters.Length == 1 && + member.HasNameOrExplicitInterfaceImplementationName(nameof(IComparable.CompareTo)) && member is IMethodSymbol { Arity: 0, Parameters.Length: 1, } method && 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 && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.EqualityOperatorName, IsStatic: true, Parameters.Length: 2, } method && 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 && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.InequalityOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= ValueObjectTypeComponents.GreaterThanOperator.If(members.Any(member => - member.Name == "op_GreaterThan" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= ValueObjectTypeComponents.LessThanOperator.If(members.Any(member => - member.Name == "op_LessThan" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= ValueObjectTypeComponents.GreaterEqualsOperator.If(members.Any(member => - member.Name == "op_GreaterThanOrEqual" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= ValueObjectTypeComponents.LessEqualsOperator.If(members.Any(member => - member.Name == "op_LessThanOrEqual" && member is IMethodSymbol method && method.Parameters.Length == 2 && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); @@ -271,11 +271,9 @@ namespace {containingNamespace} {{ /* 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.SpecialType == SpecialType.System_String) + {(isRecord || existingComponents.HasFlags(ValueObjectTypeComponents.StringComparison) ? "//" : "")}{(dataMembers.Any(member => member.Type.SpecialType == SpecialType.System_String) ? @"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 @@ -350,26 +348,14 @@ private static int Compare(T left, T right) {(isComparable ? "" : "*/")} {(existingComponents.HasFlags(ValueObjectTypeComponents.CompareToMethod) ? "*/" : "")} - {(existingComponents.HasFlags(ValueObjectTypeComponents.EqualsOperator) ? "/*" : "")} - public static bool operator ==({typeName}? left, {typeName}? right) => left is null ? right is null : left.Equals(right); - {(existingComponents.HasFlags(ValueObjectTypeComponents.EqualsOperator) ? "*/" : "")} - {(existingComponents.HasFlags(ValueObjectTypeComponents.NotEqualsOperator) ? "/*" : "")} - public static bool operator !=({typeName}? left, {typeName}? right) => !(left == right); - {(existingComponents.HasFlags(ValueObjectTypeComponents.NotEqualsOperator) ? "*/" : "")} + {(existingComponents.HasFlags(ValueObjectTypeComponents.EqualsOperator) ? "//" : "")}public static bool operator ==({typeName}? left, {typeName}? right) => left is null ? right is null : left.Equals(right); + {(existingComponents.HasFlags(ValueObjectTypeComponents.NotEqualsOperator) ? "//" : "")}public static bool operator !=({typeName}? left, {typeName}? right) => !(left == right); {(isComparable ? "" : "/*")} - {(existingComponents.HasFlags(ValueObjectTypeComponents.GreaterThanOperator) ? "/*" : "")} - public static bool operator >({typeName}? left, {typeName}? right) => left is null ? false : left.CompareTo(right) > 0; - {(existingComponents.HasFlags(ValueObjectTypeComponents.GreaterThanOperator) ? "*/" : "")} - {(existingComponents.HasFlags(ValueObjectTypeComponents.LessThanOperator) ? "/*" : "")} - public static bool operator <({typeName}? left, {typeName}? right) => left is null ? right is not null : left.CompareTo(right) < 0; - {(existingComponents.HasFlags(ValueObjectTypeComponents.LessThanOperator) ? "*/" : "")} - {(existingComponents.HasFlags(ValueObjectTypeComponents.GreaterEqualsOperator) ? "/*" : "")} - public static bool operator >=({typeName}? left, {typeName}? right) => !(left < right); - {(existingComponents.HasFlags(ValueObjectTypeComponents.GreaterEqualsOperator) ? "*/" : "")} - {(existingComponents.HasFlags(ValueObjectTypeComponents.LessEqualsOperator) ? "/*" : "")} - public static bool operator <=({typeName}? left, {typeName}? right) => !(left > right); - {(existingComponents.HasFlags(ValueObjectTypeComponents.LessEqualsOperator) ? "*/" : "")} + {(existingComponents.HasFlags(ValueObjectTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({typeName}? left, {typeName}? right) => left is null ? false : left.CompareTo(right) > 0; + {(existingComponents.HasFlags(ValueObjectTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({typeName}? left, {typeName}? right) => left is null ? right is not null : left.CompareTo(right) < 0; + {(existingComponents.HasFlags(ValueObjectTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({typeName}? left, {typeName}? right) => !(left < right); + {(existingComponents.HasFlags(ValueObjectTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({typeName}? left, {typeName}? right) => !(left > right); {(isComparable ? "" : "*/")} }} }} diff --git a/DomainModeling.Generator/ValueWrapperGenerator.cs b/DomainModeling.Generator/ValueWrapperGenerator.cs index bedb089..42493ec 100644 --- a/DomainModeling.Generator/ValueWrapperGenerator.cs +++ b/DomainModeling.Generator/ValueWrapperGenerator.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Runtime.InteropServices; using Microsoft.CodeAnalysis; namespace Architect.DomainModeling.Generator; @@ -23,43 +24,78 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var valueWrappers = identities.Combine(wrapperValueObjects) .Select((tuple, ct) => tuple.Left.AddRange(tuple.Right)); - this.IdentityGenerator.Generate(context, identities, valueWrappers); - this.WrapperValueObjectGenerator.Generate(context, wrapperValueObjects, valueWrappers); + this.IdentityGenerator.Generate(context, valueWrappers); + this.WrapperValueObjectGenerator.Generate(context, valueWrappers); } + internal static string GetCoreTypeFullyQualifiedName( + ImmutableArray valueWrappers, + string typeName, string containingNamespace) + { + // Concatenate our namespace and name, so that we are similar to further iterations + Span initialFullyQualifiedTypeName = stackalloc char[containingNamespace.Length + 1 + typeName.Length]; + initialFullyQualifiedTypeName = [.. containingNamespace, '.', .. typeName]; + + var result = (string?)null; + + var nextTypeName = (ReadOnlySpan)initialFullyQualifiedTypeName; + bool couldDigDeeper; + do + { + couldDigDeeper = false; + foreach (ref readonly var item in valueWrappers.AsSpan()) + { + // Based on the fully qualified type name we are looking for, try to find the corresponding generatable + if (item.ContainingNamespace.Length + 1 + item.TypeName.Length == nextTypeName.Length && + nextTypeName.EndsWith(item.TypeName.AsSpan()) && + nextTypeName.StartsWith(item.ContainingNamespace.AsSpan()) && + nextTypeName[item.ContainingNamespace.Length] == '.') + { + couldDigDeeper = true; + result = item.CustomCoreTypeFullyQualifiedName ?? item.UnderlyingTypeFullyQualifiedName; + nextTypeName = result.AsSpan(); + break; + } + } + } while (couldDigDeeper); + + return result ?? initialFullyQualifiedTypeName.ToString(); + } + + // ATTENTION: This method cannot be combined with the other recursive one, because this one's results are affected by intermediate items, not just the deepest item /// /// Utility method that recursively determines which formatting and parsing interfaces are supported, based on all known value wrappers. /// This allows even nested value wrappers to dig down into the deepest underlying type. /// internal static (bool isSpanFormattable, bool isSpanParsable, bool isUtf8SpanFormattable, bool isUtf8SpanParsable) GetFormattabilityAndParsabilityRecursively( - ImmutableArray valueWrapperGeneratables, - string typeName, string containingNamespace, string underlyingTypeFullyQualifiedName) + ImmutableArray valueWrappers, + string typeName, string containingNamespace) { var isSpanFormattable = false; var isSpanParsable = false; var isUtf8SpanFormattable = false; var isUtf8SpanParsable = false; - // Concatenate our namespace and name, so that we are comparable to further iterations - Span ownFullyQualifiedTypeName = stackalloc char[containingNamespace.Length + 1 + typeName.Length]; - ownFullyQualifiedTypeName = [.. containingNamespace, '.', .. typeName]; + // Concatenate our namespace and name, so that we are similar to further iterations + Span initialFullyQualifiedTypeName = stackalloc char[containingNamespace.Length + 1 + typeName.Length]; + initialFullyQualifiedTypeName = [.. containingNamespace, '.', .. typeName]; // A generated type will honor the formattability/parsability of its underlying type // As such, for generated types, it is worth recursing into the underlying types to discover if formattability/parsability is available through the chain - var nextTypeName = (ReadOnlySpan)ownFullyQualifiedTypeName; - bool hasUnderlyingGeneratedType; + var nextTypeName = (ReadOnlySpan)initialFullyQualifiedTypeName; + bool couldDigDeeper; do { - hasUnderlyingGeneratedType = false; - foreach (ref readonly var item in valueWrapperGeneratables.AsSpan()) + couldDigDeeper = false; + foreach (ref readonly var item in valueWrappers.AsSpan()) { - // Based on the fully qualified type name we are looking for, try to find the corresponding NameOnlyGeneratable - if (nextTypeName.EndsWith(item.TypeName.AsSpan()) && + // Based on the fully qualified type name we are looking for, try to find the corresponding generatable + if (item.ContainingNamespace.Length + 1 + item.TypeName.Length == nextTypeName.Length && + nextTypeName.EndsWith(item.TypeName.AsSpan()) && nextTypeName.StartsWith(item.ContainingNamespace.AsSpan()) && - item.ContainingNamespace.Length + 1 + item.TypeName.Length == nextTypeName.Length && nextTypeName[item.ContainingNamespace.Length] == '.') { - hasUnderlyingGeneratedType = true; + couldDigDeeper = true; nextTypeName = item.UnderlyingTypeFullyQualifiedName.AsSpan(); isSpanFormattable |= item.IsSpanFormattable; isSpanParsable |= item.IsSpanParsable; @@ -68,33 +104,44 @@ internal static (bool isSpanFormattable, bool isSpanParsable, bool isUtf8SpanFor break; } } - } while (hasUnderlyingGeneratedType && (isSpanFormattable & isSpanParsable & isUtf8SpanFormattable & isUtf8SpanParsable) == false); // Possible & worth seeking deeper + } while (couldDigDeeper && (isSpanFormattable & isSpanParsable & isUtf8SpanFormattable & isUtf8SpanParsable) == false); // Possible & worth seeking deeper return (isSpanFormattable, isSpanParsable, isUtf8SpanFormattable, isUtf8SpanParsable); } + [StructLayout(LayoutKind.Auto)] internal readonly record struct BasicGeneratable { + public bool IsIdentity { get; } public string TypeName { get; } public string ContainingNamespace { get; } public string UnderlyingTypeFullyQualifiedName { get; } + /// + /// Set only if manually chosen by the developer. + /// Helps implement wrappers around unofficial wrapper type, such as a WrapperValueObject<Uri> that pretends its core type is string. + /// + public string? CustomCoreTypeFullyQualifiedName { get; } public bool IsSpanFormattable { get; } public bool IsSpanParsable { get; } public bool IsUtf8SpanFormattable { get; } public bool IsUtf8SpanParsable { get; } public BasicGeneratable( + bool isIdentity, string typeName, string containingNamespace, string underlyingTypeFullyQualifiedName, + string? customCoreTypeFullyQualifiedName, bool isSpanFormattable, bool isSpanParsable, bool isUtf8SpanFormattable, bool isUtf8SpanParsable) { + this.IsIdentity = isIdentity; this.TypeName = typeName; this.ContainingNamespace = containingNamespace; this.UnderlyingTypeFullyQualifiedName = underlyingTypeFullyQualifiedName; + this.CustomCoreTypeFullyQualifiedName = customCoreTypeFullyQualifiedName; this.IsSpanFormattable = isSpanFormattable; this.IsSpanParsable = isSpanParsable; this.IsUtf8SpanFormattable = isUtf8SpanFormattable; diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index b8c1b14..5986ee8 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -27,18 +27,22 @@ internal void InitializeBasicProvider(IncrementalGeneratorInitializationContext (context, ct) => context.SemanticModel.GetDeclaredSymbol((TypeDeclarationSyntax)context.Node) switch { INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attribute.AttributeClass!.TypeArguments[0] is ITypeSymbol underlyingType => - new ValueWrapperGenerator.BasicGeneratable( - typeName: type.Name, - containingNamespace: type.ContainingNamespace.ToString(), - underlyingTypeFullyQualifiedName: underlyingType.ToString() is string underlyingTypeName ? underlyingTypeName : (underlyingTypeName = null!), - isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), - isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }), - isUtf8SpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), - isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), + GetFirstProblem((TypeDeclarationSyntax)context.Node, type, underlyingType) is { } + ? default + : new ValueWrapperGenerator.BasicGeneratable( + isIdentity: false, + typeName: type.Name, + containingNamespace: type.ContainingNamespace.ToString(), + underlyingTypeFullyQualifiedName: underlyingType.ToString() is string underlyingTypeName ? underlyingTypeName : (underlyingTypeName = null!), + customCoreTypeFullyQualifiedName: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[1].ToString(), + isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), + isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }), + isUtf8SpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), + isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), _ => default, }) .Where(generatable => generatable != default) @@ -50,8 +54,8 @@ INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attr /// Additionally gathers detailed info per individual wrapper value object. /// Generates source based on all of the above. /// - internal void Generate(IncrementalGeneratorInitializationContext context, - IncrementalValueProvider> wrapperValueObjects, + internal void Generate( + IncrementalGeneratorInitializationContext context, IncrementalValueProvider> valueWrappers) { var provider = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, TransformSyntaxNode) @@ -60,7 +64,7 @@ internal void Generate(IncrementalGeneratorInitializationContext context, context.RegisterSourceOutput(provider.Combine(valueWrappers), GenerateSource!); - var aggregatedProvider = wrapperValueObjects.Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + var aggregatedProvider = valueWrappers.Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForWrapperValueObjects); } @@ -73,6 +77,70 @@ private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeDat return attribute != null; } + private static Diagnostic? GetFirstProblem(TypeDeclarationSyntax tds, INamedTypeSymbol type, ITypeSymbol underlyingType) + { + var isPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); + + // Require the expected inheritance + if (!isPartial && !type.IsOrImplementsInterface(type => type.IsType("IWrapperValueObject", "Architect", "DomainModeling", arity: 1), out _)) + return CreateDiagnostic("WrapperValueObjectGeneratorUnexpectedInheritance", "Unexpected inheritance", + "Type marked as wrapper value object lacks IWrapperValueObject interface. Did you forget the 'partial' keyword and elude source generation?", DiagnosticSeverity.Warning); + + // Require IDirectValueWrapper + var hasDirectValueWrapperInterface = type.AllInterfaces.Any(interf => + interf.IsType("IDirectValueWrapper", "Architect", "DomainModeling", arity: 2) && !interf.IsImplicitlyDeclared && + interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default) && interf.TypeArguments[1].Equals(underlyingType, SymbolEqualityComparer.Default)); + if (!isPartial && !hasDirectValueWrapperInterface) + return CreateDiagnostic("WrapperValueObjectGeneratorMissingDirectValueWrapper", "Missing interface", + $"Type marked as identity value object lacks IDirectValueWrapper<{type.Name}, {underlyingType.Name}> interface.", DiagnosticSeverity.Warning); + + // Require ICoreValueWrapper + var hasCoreValueWrapperInterface = type.AllInterfaces.Any(interf => + interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2) && !interf.IsImplicitlyDeclared && + interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default)); + if (!isPartial && !hasCoreValueWrapperInterface) + return CreateDiagnostic("WrapperValueObjectGeneratorMissingCoreValueWrapper", "Missing interface", + $"Type marked as identity value object lacks ICoreValueWrapper<{type.Name}, {underlyingType.Name}> interface.", DiagnosticSeverity.Warning); + + if (isPartial) + { + // Only if class + if (tds is not ClassDeclarationSyntax) + return CreateDiagnostic("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); + + // Only if non-record + if (type.IsRecord) + return CreateDiagnostic("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); + + // Only if non-abstract + if (type.IsAbstract) + return CreateDiagnostic("WrapperValueObjectGeneratorAbstractType", "Source-generated abstract type", + "The type was not source-generated because it is abstract. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning); + + // Only if non-generic + if (type.IsGeneric()) + return CreateDiagnostic("WrapperValueObjectGeneratorGenericType", "Source-generated generic type", + "The type was not source-generated because it is generic. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning); + + // Only if non-nested + if (type.IsNested()) + return CreateDiagnostic("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. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning); + } + + return null; + + // Local shorthand to create a diagnostic + Diagnostic CreateDiagnostic(string id, string title, string description, DiagnosticSeverity severity) + { + return Diagnostic.Create( + new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true), + type.Locations.FirstOrDefault()); + } + } + private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) { // Struct or class or record @@ -104,9 +172,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella var underlyingType = attribute.AttributeClass!.TypeArguments[0]; var result = new Generatable(); - result.TypeLocation = type.Locations.FirstOrDefault(); result.IsWrapperValueObject = type.IsOrImplementsInterface(type => type.IsType("IWrapperValueObject", "Architect", "DomainModeling", arity: 1), out _); - result.IsSerializableDomainObject = type.IsOrImplementsInterface(type => type.IsType("ISerializableDomainObject", "Architect", "DomainModeling", arity: 2), out _); result.IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); result.IsRecord = type.IsRecord; result.IsClass = type.TypeKind == TypeKind.Class; @@ -128,8 +194,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella result.UnderlyingTypeIsNullable = underlyingType.IsNullable(); result.UnderlyingTypeIsString = underlyingType.SpecialType == SpecialType.System_String; - result.ValueFieldName = type.GetMembers().FirstOrDefault(member => member is IFieldSymbol field && (field.Name == "k__BackingField" || field.Name.Equals("value") || field.Name.Equals("_value")))?.Name ?? - "_value"; + result.ValueFieldName = type.GetMembers().FirstOrDefault(member => member is IFieldSymbol { Name: "k__BackingField" or "value" or "_value" })?.Name ?? "_value"; // IComparable is implemented on-demand, if the type implements IComparable against itself and the underlying type is self-comparable // It is also implemented if the underlying type is an annotated identity result.IsComparable = type.AllInterfaces.Any(interf => interf.IsSystemType("IComparable", arity: 1) && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default)) && @@ -152,98 +217,88 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // Records override this, but our implementation is superior existingComponents |= WrapperValueObjectTypeComponents.ToStringOverride.If(members.Any(member => - member.Name == nameof(ToString) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Parameters.Length == 0)); + member is IMethodSymbol { Name: nameof(ToString), IsImplicitlyDeclared: false, IsOverride: true, Parameters.Length: 0, })); // Records override this, but our implementation is superior existingComponents |= WrapperValueObjectTypeComponents.GetHashCodeOverride.If(members.Any(member => - member.Name == nameof(GetHashCode) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Parameters.Length == 0)); + member is IMethodSymbol { Name: nameof(GetHashCode), IsImplicitlyDeclared: false, IsOverride: true, 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 && + member is IMethodSymbol { Name: nameof(Equals), IsOverride: true, Parameters.Length: 1, } method && method.Parameters[0].Type.SpecialType == SpecialType.System_Object)); // Records override this, but our implementation is superior existingComponents |= WrapperValueObjectTypeComponents.EqualsMethod.If(members.Any(member => - member.Name == nameof(Equals) && member is IMethodSymbol { IsImplicitlyDeclared: false } method && method.Parameters.Length == 1 && + member.HasNameOrExplicitInterfaceImplementationName(nameof(Equals)) && member is IMethodSymbol { IsImplicitlyDeclared: false, IsOverride: false, Parameters.Length: 1, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.CompareToMethod.If(members.Any(member => - member.Name == nameof(IComparable.CompareTo) && member is IMethodSymbol method && method.Parameters.Length == 1 && + member.HasNameOrExplicitInterfaceImplementationName(nameof(IComparable.CompareTo)) && member is IMethodSymbol { Arity: 0, Parameters.Length: 1, } method && 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 is IMethodSymbol method && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_Equality") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.EqualityOperatorName, IsStatic: true, Parameters.Length: 2, } method && 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 is IMethodSymbol method && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_Inequality") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.InequalityOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.GreaterThanOperator.If(members.Any(member => - member is IMethodSymbol method && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_GreaterThan") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.LessThanOperator.If(members.Any(member => - member is IMethodSymbol method && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_LessThan") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.GreaterEqualsOperator.If(members.Any(member => - member is IMethodSymbol method && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_GreaterThanOrEqual") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.LessEqualsOperator.If(members.Any(member => - member is IMethodSymbol method && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("op_LessThanOrEqual") && + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.ConvertToOperator.If(members.Any(member => - member is IMethodSymbol method && method.Parameters.Length == 1 && - (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && method.ReturnType.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.ConvertFromOperator.If(members.Any(member => - member is IMethodSymbol method && method.Parameters.Length == 1 && - (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default) && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); // Consider having a reference-typed underlying type as already having the operator (though actually it does not apply at all) existingComponents |= WrapperValueObjectTypeComponents.NullableConvertToOperator.If(!underlyingType.IsValueType || members.Any(member => - member is IMethodSymbol method && method.Parameters.Length == 1 && - (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && method.ReturnType.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[0].Type.IsNullableOf(underlyingType))); // Consider having a reference-typed underlying type as already having the operator (though actually it does not apply at all) existingComponents |= WrapperValueObjectTypeComponents.NullableConvertFromOperator.If(!underlyingType.IsValueType || members.Any(member => - member is IMethodSymbol method && method.Parameters.Length == 1 && - (member.HasNameOrExplicitInterfaceImplementationName("op_Implicit") || member.HasNameOrExplicitInterfaceImplementationName("op_Explicit")) && + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && method.ReturnType.IsNullableOf(underlyingType) && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.SerializeToUnderlying.If(members.Any(member => - member.HasNameOrExplicitInterfaceImplementationName("Serialize") && member is IMethodSymbol method && method.Parameters.Length == 0 && - method.Arity == 0)); + member.HasNameOrExplicitInterfaceImplementationName("Serialize") && member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 0, } method && + method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.DeserializeFromUnderlying.If(members.Any(member => - member.HasNameOrExplicitInterfaceImplementationName("Deserialize") && member is IMethodSymbol method && method.Parameters.Length == 1 && + member.HasNameOrExplicitInterfaceImplementationName("Deserialize") && member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 1, } method && method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default) && - method.Arity == 0)); + method.ReturnType.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= WrapperValueObjectTypeComponents.SystemTextJsonConverter.If(type.GetAttributes().Any(attribute => attribute.AttributeClass?.IsTypeWithNamespace("JsonConverterAttribute", "System.Text.Json.Serialization") == true)); @@ -254,75 +309,75 @@ 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.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); - - existingComponents |= WrapperValueObjectTypeComponents.ParsableTryParseMethod.If( - members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && - member.HasNameOrExplicitInterfaceImplementationName("TryParse") && - method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); - - existingComponents |= WrapperValueObjectTypeComponents.ParsableParseMethod.If( - members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("Parse") && - method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); - - existingComponents |= WrapperValueObjectTypeComponents.SpanFormattableTryFormatMethod.If( - members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && - member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && - method.Parameters[0].Type.IsSpanOfSpecialType(SpecialType.System_Char) && - method.Parameters[1].Type.SpecialType == SpecialType.System_Int32 && method.Parameters[1].RefKind == RefKind.Out && - method.Parameters[2].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && - method.Parameters[3].Type.IsSystemType("IFormatProvider"))); - - existingComponents |= WrapperValueObjectTypeComponents.SpanParsableTryParseMethod.If( - members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && - member.HasNameOrExplicitInterfaceImplementationName("TryParse") && - method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && - method.Parameters[1].Type.IsSystemType("IFormatProvider") && - method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); - - existingComponents |= WrapperValueObjectTypeComponents.SpanParsableParseMethod.If( - members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("Parse") && - method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && - method.Parameters[1].Type.IsSystemType("IFormatProvider"))); - - existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanFormattableTryFormatMethod.If( - members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 4 && - member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && - method.Parameters[0].Type.IsSpanOfSpecialType(SpecialType.System_Byte) && - method.Parameters[1].Type.SpecialType == SpecialType.System_Int32 && method.Parameters[1].RefKind == RefKind.Out && - method.Parameters[2].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && - method.Parameters[3].Type.IsSystemType("IFormatProvider"))); - - existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanParsableTryParseMethod.If( - members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && - member.HasNameOrExplicitInterfaceImplementationName("TryParse") && - method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Byte) && - method.Parameters[1].Type.IsSystemType("IFormatProvider") && - method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); - - existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanParsableParseMethod.If( - members.Any(member => - member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - member.HasNameOrExplicitInterfaceImplementationName("Parse") && - method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Byte) && - method.Parameters[1].Type.IsSystemType("IFormatProvider"))); + existingComponents |= WrapperValueObjectTypeComponents.FormattableToStringOverride.If(members.Any(member => + member.HasNameOrExplicitInterfaceImplementationName("ToString") && member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 2, } method && + method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); + + existingComponents |= WrapperValueObjectTypeComponents.ParsableTryParseMethod.If(members.Any(member => + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 3, } method && + member.HasNameOrExplicitInterfaceImplementationName("TryParse") && + method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider") && method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + + existingComponents |= WrapperValueObjectTypeComponents.ParsableParseMethod.If(members.Any(member => + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 2, } method && + member.HasNameOrExplicitInterfaceImplementationName("Parse") && + method.Parameters[0].Type.SpecialType == SpecialType.System_String && method.Parameters[1].Type.IsSystemType("IFormatProvider"))); + + existingComponents |= WrapperValueObjectTypeComponents.SpanFormattableTryFormatMethod.If(members.Any(member => + member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 4, } method && + member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && + method.Parameters[0].Type.IsSpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[1].Type.SpecialType == SpecialType.System_Int32 && method.Parameters[1].RefKind == RefKind.Out && + method.Parameters[2].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[3].Type.IsSystemType("IFormatProvider"))); + + existingComponents |= WrapperValueObjectTypeComponents.SpanParsableTryParseMethod.If(members.Any(member => + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 3, } method && + member.HasNameOrExplicitInterfaceImplementationName("TryParse") && + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[1].Type.IsSystemType("IFormatProvider") && + method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + + existingComponents |= WrapperValueObjectTypeComponents.SpanParsableParseMethod.If(members.Any(member => + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 2, } method && + member.HasNameOrExplicitInterfaceImplementationName("Parse") && + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[1].Type.IsSystemType("IFormatProvider"))); + + existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanFormattableTryFormatMethod.If(members.Any(member => + member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 4, } method && + member.HasNameOrExplicitInterfaceImplementationName("TryFormat") && + method.Parameters[0].Type.IsSpanOfSpecialType(SpecialType.System_Byte) && + method.Parameters[1].Type.SpecialType == SpecialType.System_Int32 && method.Parameters[1].RefKind == RefKind.Out && + method.Parameters[2].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Char) && + method.Parameters[3].Type.IsSystemType("IFormatProvider"))); + + existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanParsableTryParseMethod.If(members.Any(member => + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 3, } method && + member.HasNameOrExplicitInterfaceImplementationName("TryParse") && + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Byte) && + method.Parameters[1].Type.IsSystemType("IFormatProvider") && + method.Parameters[2].Type.Equals(type, SymbolEqualityComparer.Default) && method.Parameters[2].RefKind == RefKind.Out)); + + existingComponents |= WrapperValueObjectTypeComponents.Utf8SpanParsableParseMethod.If(members.Any(member => + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 2, } method && + member.HasNameOrExplicitInterfaceImplementationName("Parse") && + method.Parameters[0].Type.IsReadOnlySpanOfSpecialType(SpecialType.System_Byte) && + method.Parameters[1].Type.IsSystemType("IFormatProvider"))); existingComponents |= WrapperValueObjectTypeComponents.CreateMethod.If(members.Any(member => - member is IMethodSymbol method && method.IsStatic && method.Arity == 0 && method.Parameters.Length == 1 && + member is IMethodSymbol { Arity: 0, IsStatic: true, Parameters.Length: 1, } method && member.HasNameOrExplicitInterfaceImplementationName("Create") && - method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); + method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default) && + method.ReturnType.Equals(type, SymbolEqualityComparer.Default))); + + existingComponents |= WrapperValueObjectTypeComponents.DirectValueWrapperInterface.If(type.AllInterfaces.Any(interf => + interf.IsType("IDirectValueWrapper", "Architect", "DomainModeling", arity: 2) && !interf.IsImplicitlyDeclared && + interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default) && interf.TypeArguments[1].Equals(underlyingType, SymbolEqualityComparer.Default))); + + existingComponents |= WrapperValueObjectTypeComponents.CoreValueWrapperInterface.If(type.AllInterfaces.Any(interf => + interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2) && !interf.IsImplicitlyDeclared && + interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default))); result.ExistingComponents = existingComponents; result.ValueMemberLocation = members.FirstOrDefault(member => member.Name == "Value" && member is IFieldSymbol or IPropertySymbol)?.Locations.FirstOrDefault(); @@ -331,6 +386,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella result.IsToStringNullable = !members.Any(member => member.Name == "Value" && member is IPropertySymbol { GetMethod.ReturnType: { SpecialType: SpecialType.System_String, NullableAnnotation: NullableAnnotation.NotAnnotated, } }); + result.Problem = GetFirstProblem(tds, type, underlyingType); + return result; } @@ -341,69 +398,17 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var generatable = input.Generatable; var valueWrappers = input.ValueWrappers; - (var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( - valueWrappers, - typeName: generatable.TypeName, containingNamespace: generatable.ContainingNamespace, underlyingTypeFullyQualifiedName: generatable.UnderlyingTypeFullyQualifiedName); - - // 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; - } + if (generatable.Problem is not null) + context.ReportDiagnostic(generatable.Problem); - // Require ISerializableDomainObject - if (!generatable.IsPartial && !generatable.IsSerializableDomainObject) - { - context.ReportDiagnostic("WrapperValueObjectGeneratorMissingSerializableDomainObject", "Missing interface", - "Type marked as wrapper value object lacks ISerializableDomainObject interface.", DiagnosticSeverity.Warning, generatable.TypeLocation); + if (generatable.Problem is not null || !generatable.IsPartial) 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; - } + var coreTypeFullyQualifiedName = ValueWrapperGenerator.GetCoreTypeFullyQualifiedName(valueWrappers, generatable.TypeName, generatable.ContainingNamespace); - // Only if non-abstract - if (generatable.IsAbstract) - { - context.ReportDiagnostic("WrapperValueObjectGeneratorAbstractType", "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.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. 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. To disable source generation, remove the 'partial' keyword.", DiagnosticSeverity.Warning, generatable.TypeLocation); - return; - } + (var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( + valueWrappers, + typeName: generatable.TypeName, containingNamespace: generatable.ContainingNamespace); var typeName = generatable.TypeName; var containingNamespace = generatable.ContainingNamespace; @@ -417,7 +422,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable : $"Wrapper<{typeName}, {underlyingTypeFullyQualifiedName}>"; // Warn if Value is not settable - if (existingComponents.HasFlag(WrapperValueObjectTypeComponents.UnsettableValue)) + if (existingComponents.HasFlags(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); @@ -426,6 +431,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Architect.DomainModeling; using Architect.DomainModeling.Conversions; @@ -433,24 +439,18 @@ private static void GenerateSource(SourceProductionContext context, (Generatable namespace {containingNamespace} {{ - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "*/" : "")} - - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} - + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} /* Generated */ {generatable.Accessibility.ToCodeString()} sealed partial{(generatable.IsRecord ? " record" : "")} class {typeName} : WrapperValueObject<{underlyingTypeFullyQualifiedName}>, - IValueWrapper<{typeName}, {underlyingTypeFullyQualifiedName}>, IEquatable<{typeName}>, {(isComparable ? "" : "//")}IComparable<{typeName}>, {(isSpanFormattable ? "" : "//")}ISpanFormattable, ISpanFormattable{formattableParsableWrapperSuffix}, {(isSpanParsable ? "" : "//")}ISpanParsable<{typeName}>, ISpanParsable{formattableParsableWrapperSuffix}, {(isUtf8SpanFormattable ? "" : "//")}IUtf8SpanFormattable, IUtf8SpanFormattable{formattableParsableWrapperSuffix}, {(isUtf8SpanParsable ? "" : "//")}IUtf8SpanParsable<{typeName}>, IUtf8SpanParsable{formattableParsableWrapperSuffix}, - ISerializableDomainObject<{typeName}, {underlyingTypeFullyQualifiedName}> + IDirectValueWrapper<{typeName}, {underlyingTypeFullyQualifiedName}>, + ICoreValueWrapper<{typeName}, {coreTypeFullyQualifiedName}> {{ {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.StringComparison) ? "/*" : "")} {(generatable.UnderlyingTypeIsString ? "" : @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");")} @@ -477,6 +477,7 @@ namespace {containingNamespace} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DefaultConstructor) ? "*/" : "")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CreateMethod) ? "/*" : "")} + [MethodImpl(MethodImplOptions.AggressiveInlining)] static {typeName} IValueWrapper<{typeName}, {underlyingTypeFullyQualifiedName}>.Create({underlyingTypeFullyQualifiedName} value) {{ return new {typeName}(value); @@ -487,7 +488,9 @@ namespace {containingNamespace} /// /// Serializes a domain object as a plain value. /// - {underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} ISerializableDomainObject<{typeName}, {underlyingTypeFullyQualifiedName}>.Serialize() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: MaybeNull] + {underlyingTypeFullyQualifiedName} IValueWrapper<{typeName}, {underlyingTypeFullyQualifiedName}>.Serialize() {{ return this.Value; }} @@ -501,7 +504,8 @@ namespace {containingNamespace} /// /// Deserializes a plain value back into a domain object, without using a parameterized constructor. /// - static {typeName} ISerializableDomainObject<{typeName}, {underlyingTypeFullyQualifiedName}>.Deserialize({underlyingTypeFullyQualifiedName} value) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static {typeName} IValueWrapper<{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) @@ -512,6 +516,39 @@ namespace {containingNamespace} }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} + {(generatable.ExistingComponents.HasFlags(WrapperValueObjectTypeComponents.CoreValueWrapperInterface) ? "/* Core manually specified" : coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "/* For nested wrapper types only" : "")} + [MaybeNull] + {coreTypeFullyQualifiedName} IValueWrapper<{typeName}, {coreTypeFullyQualifiedName}>.Value => ValueWrapperUnwrapper.Unwrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(this.Value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static {typeName} IValueWrapper<{typeName}, {coreTypeFullyQualifiedName}>.Create({coreTypeFullyQualifiedName} value) + {{ + var intermediateValue = ValueWrapperUnwrapper.Wrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(value); + return ValueWrapperUnwrapper.Wrap<{typeName}, {underlyingTypeFullyQualifiedName}>(intermediateValue); + }} + + /// + /// Serializes a domain object as a plain value. + /// + [return: MaybeNull] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + {coreTypeFullyQualifiedName} IValueWrapper<{typeName}, {coreTypeFullyQualifiedName}>.Serialize() + {{ + return DomainObjectSerializer.Serialize<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>( + DomainObjectSerializer.Serialize<{typeName}, {underlyingTypeFullyQualifiedName}>(this)); + }} + + /// + /// Deserializes a plain value back into a domain object, without using a parameterized constructor. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static {typeName} IValueWrapper<{typeName}, {coreTypeFullyQualifiedName}>.Deserialize({coreTypeFullyQualifiedName} value) + {{ + var intermediateValue = DomainObjectSerializer.Deserialize<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(value); + return DomainObjectSerializer.Deserialize<{typeName}, {underlyingTypeFullyQualifiedName}>(intermediateValue); + }} + {(coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "*/" : generatable.ExistingComponents.HasFlags(WrapperValueObjectTypeComponents.CoreValueWrapperInterface) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ToStringOverride) ? "/*" : "")} public sealed override string{(generatable.IsToStringNullable ? "?" : "")} ToString() {{ @@ -555,47 +592,26 @@ public int CompareTo({typeName}? other) }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) || !isComparable ? "*/" : "")} - {(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) ? "*/" : "")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NotEqualsOperator) ? "/*" : "")} - public static bool operator !=({typeName}? left, {typeName}? right) => !(left == right); - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NotEqualsOperator) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOperator) ? "//" : "")}public static bool operator ==({typeName}? left, {typeName}? right) => left is null ? right is null : left.Equals(right); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NotEqualsOperator) ? "//" : "")}public static bool operator !=({typeName}? left, {typeName}? right) => !(left == right); {(isComparable ? "" : "/*")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterThanOperator) ? "/*" : "")} - public static bool operator >({typeName}? left, {typeName}? right) => left is null ? false : left.CompareTo(right) > 0; - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterThanOperator) ? "*/" : "")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessThanOperator) ? "/*" : "")} - public static bool operator <({typeName}? left, {typeName}? right) => left is null ? right is not null : left.CompareTo(right) < 0; - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessThanOperator) ? "*/" : "")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterEqualsOperator) ? "/*" : "")} - public static bool operator >=({typeName}? left, {typeName}? right) => !(left < right); - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterEqualsOperator) ? "*/" : "")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessEqualsOperator) ? "/*" : "")} - public static bool operator <=({typeName}? left, {typeName}? right) => !(left > right); - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessEqualsOperator) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({typeName}? left, {typeName}? right) => left is null ? false : left.CompareTo(right) > 0; + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({typeName}? left, {typeName}? right) => left is null ? right is not null : left.CompareTo(right) < 0; + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({typeName}? left, {typeName}? right) => !(left < right); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({typeName}? left, {typeName}? right) => !(left > right); {(isComparable ? "" : "*/")} - {(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) ? "*/" : "")} - - {(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) ? "*/" : "")} - - {(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) ? "*/" : "")} + {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "//" : "")} + {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? "[return: NotNull]" : @"[return: NotNullIfNotNull(""value"")]")} + {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "//" : "")}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.ConvertFromOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? "[return: NotNull]" : @"[return: NotNullIfNotNull(""instance"")]")} + {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertFromOperator) ? "//" : "")}public static implicit operator {underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")}({typeName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} instance) => instance{(generatable.UnderlyingTypeIsStruct ? "" : "?")}.Value; - {(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) ? "*/" : "")} + {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? @"[return: NotNullIfNotNull(""value"")]" : "")} + {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? $"public static explicit operator {typeName}?({underlyingTypeFullyQualifiedName}? value) => value is null ? null : new {typeName}(value.Value);" : "")} + {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? @"[return: NotNullIfNotNull(""instance"")]" : "")} + {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? $"public static implicit operator {underlyingTypeFullyQualifiedName}?({typeName}? instance) => instance?.Value;" : "")} #region Formatting & Parsing @@ -701,29 +717,30 @@ internal enum WrapperValueObjectTypeComponents : ulong Utf8SpanParsableTryParseMethod = 1UL << 31, Utf8SpanParsableParseMethod = 1UL << 32, CreateMethod = 1UL << 33, + DirectValueWrapperInterface = 1UL << 34, + CoreValueWrapperInterface = 1UL << 35, } private sealed record Generatable { 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 bool IsPartial { get => this._bits.GetBit(1); set => this._bits.SetBit(1, value); } + public bool IsRecord { get => this._bits.GetBit(2); set => this._bits.SetBit(2, value); } + public bool IsClass { get => this._bits.GetBit(3); set => this._bits.SetBit(3, value); } + public bool IsAbstract { get => this._bits.GetBit(4); set => this._bits.SetBit(4, value); } + public bool IsGeneric { get => this._bits.GetBit(5); set => this._bits.SetBit(5, value); } + public bool IsNested { get => this._bits.GetBit(6); set => this._bits.SetBit(6, value); } + public bool IsComparable { get => this._bits.GetBit(7); set => this._bits.SetBit(7, value); } public string TypeName { get; set; } = null!; public string ContainingNamespace { 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 IsToStringNullable { get => this._bits.GetBit(12); set => this._bits.SetBit(12, value); } - public bool UnderlyingTypeIsInterface { get => this._bits.GetBit(13); set => this._bits.SetBit(13, value); } + public bool UnderlyingTypeIsStruct { get => this._bits.GetBit(8); set => this._bits.SetBit(8, value); } + public bool UnderlyingTypeIsNullable { 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 IsToStringNullable { get => this._bits.GetBit(11); set => this._bits.SetBit(11, value); } + public bool UnderlyingTypeIsInterface { 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; } @@ -731,7 +748,7 @@ private sealed record Generatable 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; } + public Diagnostic? Problem { get; set; } } } diff --git a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs index 4f0c9f6..d73677d 100644 --- a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs +++ b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs @@ -1,5 +1,8 @@ +using Architect.DomainModeling.Conversions; +using Architect.DomainModeling.Tests.IdentityTestTypes; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Xunit; namespace Architect.DomainModeling.Tests.EntityFramework; @@ -25,7 +28,13 @@ public void Dispose() [Fact] public void ConfigureConventions_WithAllExtensionsCalled_ShouldBeAbleToWorkWithAllDomainObjects() { - var values = new ValueObjectForEF((Wrapper1ForEF)"One", (Wrapper2ForEF)2); + var values = new ValueObjectForEF( + (Wrapper1ForEF)"One", + (Wrapper2ForEF)2, + new FormatAndParseTestingIntId(3), + new LazyStringWrapper(new Lazy("4")), + new LazyIntWrapper(new Lazy(5)), + new NumericStringId("6")); var entity = new EntityForEF(values); var domainEvent = new DomainEventForEF(id: 2, ignored: null!); @@ -55,6 +64,18 @@ public void ConfigureConventions_WithAllExtensionsCalled_ShouldBeAbleToWorkWithA Assert.Equal(2, reloadedEntity.Id.Value); Assert.Equal("One", reloadedEntity.Values.One); Assert.Equal(2m, reloadedEntity.Values.Two); + Assert.Equal(3, reloadedEntity.Values.Three.Value?.Value.Value); + Assert.Equal("4", reloadedEntity.Values.Four.Value.Value); + Assert.Equal(5, reloadedEntity.Values.Five.Value.Value); + Assert.Equal("6", reloadedEntity.Values.Six.Value); + + // This property should be mapped to int via ICoreValueWrapper + var mappingForStringWithCustomIntCore = this.DbContext.Model.FindEntityType(typeof(EntityForEF))?.FindNavigation(nameof(EntityForEF.Values))?.TargetEntityType + .FindProperty(nameof(EntityForEF.Values.Six)); + var columnTypeForStringWrapperWithCustomIntCore = mappingForStringWithCustomIntCore?.GetColumnType(); + var providerClrTypeForStringWrapperWithCustomIntCore = mappingForStringWithCustomIntCore?.GetValueConverter()?.ProviderClrType; + Assert.Equal("INTEGER", columnTypeForStringWrapperWithCustomIntCore); + Assert.Equal(typeof(int), providerClrTypeForStringWrapperWithCustomIntCore); } } @@ -75,6 +96,22 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura domainModel.ConfigureEntityConventions(); domainModel.ConfigureDomainEventConventions(); }); + + // For a wrapper whose core type EF does not support, overwriting the conventions with our own should work + configurationBuilder.Properties() + .HaveConversion(); + configurationBuilder.DefaultTypeMapping() + .HasConversion(); + } + + private class LazyStringWrapperConverter : ValueConverter + { + public LazyStringWrapperConverter() + : base( + v => v.Value.Value, + v => new LazyStringWrapper(new Lazy(v))) + { + } } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -90,6 +127,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { values.Property(x => x.One); values.Property(x => x.Two); + values.Property(x => x.Three); + values.Property(x => x.Four); + values.Property(x => x.Five); + values.Property(x => x.Six); }); builder.HasKey(x => x.Id); @@ -192,6 +233,31 @@ public Wrapper2ForEF(decimal value) } } +[WrapperValueObject>] +internal sealed partial class LazyStringWrapper +{ +} + +[WrapperValueObject>] +internal sealed partial class LazyIntWrapper : ICoreValueWrapper // Custom core value +{ + // Manual interface implementation to support custom core value + int IValueWrapper.Value => this.Value.Value; + static LazyIntWrapper IValueWrapper.Create(int value) => new LazyIntWrapper(new Lazy(value)); + int IValueWrapper.Serialize() => this.Value.Value; + static LazyIntWrapper IValueWrapper.Deserialize(int value) => DomainObjectSerializer.Deserialize>(new Lazy(value)); +} + +[IdentityValueObject] +internal partial struct NumericStringId : ICoreValueWrapper // Custom core value +{ + // Manual interface implementation to support custom core value + int IValueWrapper.Value => Int32.Parse(this.Value); + static NumericStringId IValueWrapper.Create(int value) => new NumericStringId(value.ToString()); + int IValueWrapper.Serialize() => Int32.Parse(this.Value); + static NumericStringId IValueWrapper.Deserialize(int value) => DomainObjectSerializer.Deserialize(value.ToString()); +} + [ValueObject] internal sealed partial class ValueObjectForEF { @@ -202,13 +268,21 @@ internal sealed partial class ValueObjectForEF public Wrapper1ForEF One { get; private init; } public Wrapper2ForEF Two { get; private init; } + public FormatAndParseTestingIntId Three { get; private init; } + public LazyStringWrapper Four { get; private init; } + public LazyIntWrapper Five { get; private init; } + public NumericStringId Six { get; private init; } - public ValueObjectForEF(Wrapper1ForEF one, Wrapper2ForEF two) + public ValueObjectForEF(Wrapper1ForEF one, Wrapper2ForEF two, FormatAndParseTestingIntId three, LazyStringWrapper four, LazyIntWrapper five, NumericStringId six) { if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors) throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors."); this.One = one; this.Two = two; + this.Three = three; + this.Four = four; + this.Five = five; + this.Six = six; } } diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index c7d5185..b8533b8 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -352,6 +352,98 @@ public void CastFromNullableUnderlyingType_Regularly_ShouldReturnExpectedResult( Assert.Equal(expectedResult, result?.Value); } + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Value_ViaCoreValueInterface_ShouldReturnExpectedResult(int value) + { + ICoreValueWrapper intInstance = + new FormatAndParseTestingIntId(value); + Assert.IsType(intInstance.Value); + Assert.Equal(value, intInstance.Value); + + ICoreValueWrapper stringInstance = + new FormatAndParseTestingStringId(new StringValue(value.ToString())); + Assert.IsType(stringInstance.Value); + Assert.Equal(value.ToString(), stringInstance.Value); + } + + /// + /// Helper to access abstract statics. + /// + private static TWrapper CreateFromDirectUnderlyingValue(TValue value) + where TWrapper : IDirectValueWrapper + { + return TWrapper.Create(value); + } + + /// + /// Helper to access abstract statics. + /// + private static TWrapper CreateFromCoreValue(TValue value) + where TWrapper : ICoreValueWrapper + { + return TWrapper.Create(value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Create_ViaDirectUnderlyingValueInterface_ShouldReturnExpectedResult(int value) + { + var intInstance = new FormatAndParseTestingIntWrapper(value); + Assert.IsType(CreateFromDirectUnderlyingValue(intInstance)); + Assert.Equal(value, CreateFromDirectUnderlyingValue(intInstance).Value?.Value.Value); + + var stringInstance = new StringValue(value.ToString()); + Assert.IsType(CreateFromDirectUnderlyingValue(stringInstance)); + Assert.Equal(value.ToString(), CreateFromDirectUnderlyingValue(stringInstance).Value?.Value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Create_ViaCoreValueInterface_ShouldReturnExpectedResult(int value) + { + Assert.IsType(CreateFromCoreValue(value)); + Assert.Equal(value, CreateFromCoreValue(value).Value?.Value.Value); + + Assert.IsType(CreateFromCoreValue(value.ToString())); + Assert.Equal(value.ToString(), CreateFromCoreValue(value.ToString()).Value?.Value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Serialize_ToImmediateUnderlyingType_ShouldReturnExpectedResult(int value) + { + IValueWrapper intInstance = + new FormatAndParseTestingIntId(value); + Assert.IsType(intInstance.Serialize()); + Assert.Equal(value, intInstance.Serialize()?.Value.Value); + + IValueWrapper stringInstance = + new FormatAndParseTestingStringId(new StringValue(value.ToString())); + Assert.IsType(stringInstance.Serialize()); + Assert.Equal(value.ToString(), stringInstance.Serialize()?.Value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Serialize_ToCoreType_ShouldReturnExpectedResult(int value) + { + IValueWrapper intInstance = + new FormatAndParseTestingIntId(value); + Assert.IsType(intInstance.Serialize()); + Assert.Equal(value, intInstance.Serialize()); + + IValueWrapper stringInstance = + new FormatAndParseTestingStringId(new StringValue(value.ToString())); + Assert.IsType(stringInstance.Serialize()); + Assert.Equal(value.ToString(), stringInstance.Serialize()); + } + [Theory] [InlineData(null)] [InlineData(0)] @@ -422,6 +514,41 @@ public void SerializeWithNewtonsoftJson_WithDecimal_ShouldReturnExpectedResult(i Assert.Equal($@"""{value}""", Newtonsoft.Json.JsonConvert.SerializeObject((DecimalId?)value)); } + /// + /// Helper to access abstract statics. + /// + private static TWrapper Deserialize(TValue value) + where TWrapper : IValueWrapper + { + return TWrapper.Deserialize(value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Deserialize_FromImmediateUnderlyingType_ShouldReturnExpectedResult(int value) + { + var intInstance = new FormatAndParseTestingIntWrapper(value); + Assert.IsType(Deserialize(intInstance)); + Assert.Equal(value, Deserialize(intInstance).Value?.Value.Value); + + var stringInstance = new StringValue(value.ToString()); + Assert.IsType(Deserialize(stringInstance)); + Assert.Equal(value.ToString(), Deserialize(stringInstance).Value?.Value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Deserialize_FromCoreType_ShouldReturnExpectedResult(int value) + { + Assert.IsType(Deserialize(value)); + Assert.Equal(value, Deserialize(value).Value?.Value.Value); + + Assert.IsType(Deserialize(value.ToString())); + Assert.Equal(value.ToString(), Deserialize(value.ToString()).Value?.Value); + } + [Theory] [InlineData("null", null)] [InlineData("0", 0)] @@ -779,7 +906,8 @@ internal readonly partial struct FullySelfImplementedIdentity ISpanParsable, IUtf8SpanFormattable, IUtf8SpanParsable, - ISerializableDomainObject + IDirectValueWrapper, + ICoreValueWrapper { public int Value { get; private init; } @@ -796,7 +924,7 @@ public static FullySelfImplementedIdentity Create(int value) /// /// Serializes a domain object as a plain value. /// - int ISerializableDomainObject.Serialize() + int IValueWrapper.Serialize() { return this.Value; } @@ -804,7 +932,7 @@ int ISerializableDomainObject.Serialize() /// /// Deserializes a plain value back into a domain object, without using a parameterized constructor. /// - static FullySelfImplementedIdentity ISerializableDomainObject.Deserialize(int value) + static FullySelfImplementedIdentity IValueWrapper.Deserialize(int value) { return new FullySelfImplementedIdentity() { Value = value }; } @@ -852,7 +980,7 @@ public override string ToString() #region Formatting & Parsing -#if !NET10_0_OR_GREATER // Starting from .NET 10, these operations are provided by default implementations and extension methods +//#if !NET10_0_OR_GREATER // Starting from .NET 10, these operations are provided by default implementations and extension methods public string ToString(string? format, IFormatProvider? formatProvider) => FormattingHelper.ToString(this.Value, format, formatProvider); @@ -887,7 +1015,7 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid public static FullySelfImplementedIdentity Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => (FullySelfImplementedIdentity)ParsingHelper.Parse(utf8Text, provider); -#endif +//#endif #endregion } diff --git a/DomainModeling.Tests/WrapperValueObjectTests.cs b/DomainModeling.Tests/WrapperValueObjectTests.cs index c34eec2..516548f 100644 --- a/DomainModeling.Tests/WrapperValueObjectTests.cs +++ b/DomainModeling.Tests/WrapperValueObjectTests.cs @@ -292,6 +292,98 @@ public void CastFromNullableUnderlyingType_Regularly_ShouldReturnExpectedResult( Assert.Equal(expectedResult, result?.Value); } + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Value_ViaCoreValueInterface_ShouldReturnExpectedResult(int value) + { + ICoreValueWrapper intInstance = + new FormatAndParseTestingIntWrapper(value); + Assert.IsType(intInstance.Value); + Assert.Equal(value, intInstance.Serialize()); + + ICoreValueWrapper stringInstance = + new FormatAndParseTestingStringWrapper(new FormatAndParseTestingNestedStringWrapper(new FormatAndParseTestingStringId(new StringValue(value.ToString())))); + Assert.IsType(stringInstance.Value); + Assert.Equal(value.ToString(), stringInstance.Value); + } + + /// + /// Helper to access abstract statics. + /// + private static TWrapper CreateFromDirectUnderlyingValue(TValue value) + where TWrapper : IDirectValueWrapper + { + return TWrapper.Create(value); + } + + /// + /// Helper to access abstract statics. + /// + private static TWrapper CreateFromCoreValue(TValue value) + where TWrapper : ICoreValueWrapper + { + return TWrapper.Create(value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Create_ViaDirectUnderlyingValueInterface_ShouldReturnExpectedResult(int value) + { + var intInstance = new IntId(value); + Assert.IsType(CreateFromDirectUnderlyingValue(intInstance)); + Assert.Equal(value, CreateFromDirectUnderlyingValue(intInstance).Value.Value); + + var stringInstance = new FormatAndParseTestingNestedStringWrapper(new FormatAndParseTestingStringId(new StringValue(value.ToString()))); + Assert.IsType(CreateFromDirectUnderlyingValue(stringInstance)); + Assert.Equal(value.ToString(), CreateFromDirectUnderlyingValue(stringInstance).Value.Value.Value?.Value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Create_ViaCoreValueInterface_ShouldReturnExpectedResult(int value) + { + Assert.IsType(CreateFromCoreValue(value)); + Assert.Equal(value, CreateFromCoreValue(value).Value.Value); + + Assert.IsType(CreateFromCoreValue(value.ToString())); + Assert.Equal(value.ToString(), CreateFromCoreValue(value.ToString()).Value.Value.Value?.Value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Serialize_ToImmediateUnderlyingType_ShouldReturnExpectedResult(int value) + { + IValueWrapper intInstance = + new FormatAndParseTestingIntWrapper(value); + Assert.IsType(intInstance.Serialize()); + Assert.Equal(value, intInstance.Serialize().Value); + + IValueWrapper stringInstance = + new FormatAndParseTestingStringWrapper(new FormatAndParseTestingNestedStringWrapper(new FormatAndParseTestingStringId(new StringValue(value.ToString())))); + Assert.IsType(stringInstance.Serialize()); + Assert.Equal(value.ToString(), stringInstance.Serialize()?.Value.Value?.Value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Serialize_ToCoreType_ShouldReturnExpectedResult(int value) + { + IValueWrapper intInstance = + new FormatAndParseTestingIntWrapper(value); + Assert.IsType(intInstance.Serialize()); + Assert.Equal(value, intInstance.Serialize()); + + IValueWrapper stringInstance = + new FormatAndParseTestingStringWrapper(new FormatAndParseTestingNestedStringWrapper(new FormatAndParseTestingStringId(new StringValue(value.ToString())))); + Assert.IsType(stringInstance.Serialize()); + Assert.Equal(value.ToString(), stringInstance.Serialize()); + } + [Theory] [InlineData(null)] [InlineData(0)] @@ -363,6 +455,41 @@ public void SerializeWithNewtonsoftJson_WithDecimal_ShouldReturnExpectedResult(i Assert.Equal(value is null ? "null" : $"{value}.0", Newtonsoft.Json.JsonConvert.SerializeObject(instance)); } + /// + /// Helper to access abstract statics. + /// + private static TWrapper Deserialize(TValue value) + where TWrapper : IValueWrapper + { + return TWrapper.Deserialize(value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Deserialize_FromImmediateUnderlyingType_ShouldReturnExpectedResult(int value) + { + var intInstance = new IntId(value); + Assert.IsType(Deserialize(intInstance)); + Assert.Equal(value, Deserialize(intInstance).Value.Value); + + var stringInstance = new FormatAndParseTestingNestedStringWrapper(new FormatAndParseTestingStringId(new StringValue(value.ToString()))); + Assert.IsType(Deserialize(stringInstance)); + Assert.Equal(value.ToString(), Deserialize(stringInstance).Value.Value.Value?.Value); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Deserialize_FromCoreType_ShouldReturnExpectedResult(int value) + { + Assert.IsType(Deserialize(value)); + Assert.Equal(value, Deserialize(value).Value.Value); + + Assert.IsType(Deserialize(value.ToString())); + Assert.Equal(value.ToString(), Deserialize(value.ToString()).Value.Value.Value?.Value); + } + [Theory] [InlineData("null", null)] [InlineData("0", 0)] @@ -798,13 +925,14 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly [Newtonsoft.Json.JsonConverter(typeof(ValueWrapperNewtonsoftJsonConverter))] internal sealed partial class FullySelfImplementedWrapperValueObject : WrapperValueObject, - IValueWrapper, + IEquatable, IComparable, ISpanFormattable, ISpanParsable, IUtf8SpanFormattable, IUtf8SpanParsable, - ISerializableDomainObject + IDirectValueWrapper, + ICoreValueWrapper { protected sealed override StringComparison StringComparison => throw new NotSupportedException("This operation applies to string-based value objects only."); @@ -828,7 +956,7 @@ static FullySelfImplementedWrapperValueObject IValueWrapper /// Serializes a domain object as a plain value. /// - int ISerializableDomainObject.Serialize() + int IValueWrapper.Serialize() { return this.Value; } @@ -836,7 +964,7 @@ int ISerializableDomainObject.Seria /// /// Deserializes a plain value back into a domain object, without using a parameterized constructor. /// - static FullySelfImplementedWrapperValueObject ISerializableDomainObject.Deserialize(int value) + static FullySelfImplementedWrapperValueObject IValueWrapper.Deserialize(int value) { #pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary #pragma warning disable CS0618 // Obsolete constructor is intended for us @@ -845,6 +973,12 @@ static FullySelfImplementedWrapperValueObject ISerializableDomainObject.Value => (long)this.Value; + static FullySelfImplementedWrapperValueObject IValueWrapper.Create(long value) => new FullySelfImplementedWrapperValueObject((int)value); + long IValueWrapper.Serialize() => (long)this.Value; + static FullySelfImplementedWrapperValueObject IValueWrapper.Deserialize(long value) => DomainObjectSerializer.Deserialize((int)value); + public sealed override int GetHashCode() { return this.Value.GetHashCode(); @@ -890,7 +1024,7 @@ public sealed override string ToString() #region Formatting & Parsing -#if !NET10_0_OR_GREATER // Starting from .NET 10, these operations are provided by default implementations and extension methods +//#if !NET10_0_OR_GREATER // Starting from .NET 10, these operations are provided by default implementations and extension methods public string ToString(string? format, IFormatProvider? formatProvider) => FormattingHelper.ToString(this.Value, format, formatProvider); @@ -925,7 +1059,7 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid public static FullySelfImplementedWrapperValueObject Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => (FullySelfImplementedWrapperValueObject)ParsingHelper.Parse(utf8Text, provider); -#endif +//#endif #endregion } diff --git a/DomainModeling/Configuration/IDomainEventConfigurator.cs b/DomainModeling/Configuration/IDomainEventConfigurator.cs index 3b005e4..9425be2 100644 --- a/DomainModeling/Configuration/IDomainEventConfigurator.cs +++ b/DomainModeling/Configuration/IDomainEventConfigurator.cs @@ -16,9 +16,8 @@ void ConfigureDomainEvent< in Args args) where TDomainEvent : IDomainObject; -#pragma warning disable IDE0040 // Remove accessibility modifiers -- We always want explicit accessibility for types + [SuppressMessage("Style", "IDE0040:Remove accessibility modifiers", Justification = "We always want explicit accessibility for types")] public readonly struct Args -#pragma warning restore IDE0040 // Remove accessibility modifiers { public readonly bool HasDefaultConstructor { get; init; } } diff --git a/DomainModeling/Configuration/IEntityConfigurator.cs b/DomainModeling/Configuration/IEntityConfigurator.cs index c244a86..bf3245a 100644 --- a/DomainModeling/Configuration/IEntityConfigurator.cs +++ b/DomainModeling/Configuration/IEntityConfigurator.cs @@ -16,9 +16,8 @@ void ConfigureEntity< in Args args) where TEntity : IEntity; -#pragma warning disable IDE0040 // Remove accessibility modifiers -- We always want explicit accessibility for types + [SuppressMessage("Style", "IDE0040:Remove accessibility modifiers", Justification = "We always want explicit accessibility for types")] public readonly struct Args -#pragma warning restore IDE0040 // Remove accessibility modifiers { public bool HasDefaultConstructor { get; init; } } diff --git a/DomainModeling/Configuration/IIdentityConfigurator.cs b/DomainModeling/Configuration/IIdentityConfigurator.cs index 4748f31..27e5bd8 100644 --- a/DomainModeling/Configuration/IIdentityConfigurator.cs +++ b/DomainModeling/Configuration/IIdentityConfigurator.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Architect.DomainModeling.Conversions; namespace Architect.DomainModeling.Configuration; @@ -13,14 +14,14 @@ public interface IIdentityConfigurator /// void ConfigureIdentity< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, - TUnderlying>( + TUnderlying, + TCore>( in Args args) - where TIdentity : IIdentity, ISerializableDomainObject + where TIdentity : IIdentity, IDirectValueWrapper, ICoreValueWrapper where TUnderlying : notnull, IEquatable, IComparable; -#pragma warning disable IDE0040 // Remove accessibility modifiers -- We always want explicit accessibility for types + [SuppressMessage("Style", "IDE0040:Remove accessibility modifiers", Justification = "We always want explicit accessibility for types")] public readonly struct Args -#pragma warning restore IDE0040 // Remove accessibility modifiers { } } diff --git a/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs b/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs index 03cb4f5..043b9bf 100644 --- a/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs +++ b/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Architect.DomainModeling.Conversions; namespace Architect.DomainModeling.Configuration; @@ -13,14 +14,14 @@ public interface IWrapperValueObjectConfigurator /// void ConfigureWrapperValueObject< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, - TValue>( + TValue, + TCore>( in Args args) - where TWrapper : IWrapperValueObject, ISerializableDomainObject + where TWrapper : IWrapperValueObject, IDirectValueWrapper, ICoreValueWrapper where TValue : notnull; -#pragma warning disable IDE0040 // Remove accessibility modifiers -- We always want explicit accessibility for types + [SuppressMessage("Style", "IDE0040:Remove accessibility modifiers", Justification = "We always want explicit accessibility for types")] public readonly struct Args -#pragma warning restore IDE0040 // Remove accessibility modifiers { } } diff --git a/DomainModeling/Conversions/DomainObjectSerializer.cs b/DomainModeling/Conversions/DomainObjectSerializer.cs index 65cc325..b9d7a2e 100644 --- a/DomainModeling/Conversions/DomainObjectSerializer.cs +++ b/DomainModeling/Conversions/DomainObjectSerializer.cs @@ -1,9 +1,18 @@ using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; +using System.Runtime.CompilerServices; namespace Architect.DomainModeling.Conversions; +/// +/// +/// Exposes serialization and deserialization methods for instances. +/// +/// +/// Domain model serialization is intended to work with trusted data and should skip validation and other logic. +/// +/// public static class DomainObjectSerializer { [UnconditionalSuppressMessage("Trimming", "IL2111", Justification = "We rely only on public methods, which we take an explicit dependency on")] @@ -29,6 +38,7 @@ public static class DomainObjectSerializer /// /// Deserializes an empty, uninitialized instance of type . /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TModel Deserialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel>() where TModel : IDomainObject { @@ -80,10 +90,11 @@ public static Expression CreateDeserializeExpression([DynamicallyAccessedMembers /// /// Deserializes a from a . /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] [return: NotNullIfNotNull(nameof(value))] public static TModel? Deserialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>( TUnderlying? value) - where TModel : ISerializableDomainObject + where TModel : IValueWrapper { return value is null ? default @@ -116,7 +127,7 @@ public static Expression CreateDeserializeExpression([DynamicallyAccessedMembers /// /// public static Expression> CreateDeserializeExpression<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>() - where TModel : ISerializableDomainObject + where TModel : IValueWrapper { var call = CreateDeserializeExpressionCore(typeof(TModel), typeof(TUnderlying), out var parameter); var lambda = Expression.Lambda>(call, parameter); @@ -140,9 +151,10 @@ private static MethodCallExpression CreateDeserializeExpressionCore([Dynamically /// /// Serializes a as a . /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TUnderlying? Serialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>( TModel? instance) - where TModel : ISerializableDomainObject + where TModel : IValueWrapper { return instance is null ? default @@ -175,7 +187,7 @@ public static Expression CreateSerializeExpression([DynamicallyAccessedMembers(D /// /// public static Expression> CreateSerializeExpression<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>() - where TModel : ISerializableDomainObject + where TModel : IValueWrapper { var call = CreateSerializeExpressionCore(typeof(TModel), typeof(TUnderlying), out var parameter); var lambda = Expression.Lambda>(call, parameter); diff --git a/DomainModeling/Conversions/IValueWrapper.cs b/DomainModeling/Conversions/IValueWrapper.cs deleted file mode 100644 index f22e90e..0000000 --- a/DomainModeling/Conversions/IValueWrapper.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace Architect.DomainModeling.Conversions; - -/// -/// An instance of wrapping a value of type in a property named Value. -/// -/// The wrapper type. -/// The type being wrapped. -public interface IValueWrapper - where TWrapper : IValueWrapper -{ - // Note that a struct implementation can always return a null value - TValue? Value { get; } - - abstract static TWrapper Create( -#nullable disable // We are used interchangeably between types with nullable vs. non-nullable values, so do not enforce either - TValue value); -#nullable enable - - virtual static bool TryCreate( -#nullable disable // We are used interchangeably between types with nullable vs. non-nullable values, so do not enforce either - TValue value, -#nullable enable - [MaybeNullWhen(false)] out TWrapper result) - { - try - { - result = TWrapper.Create(value); - return true; - } - catch - { - result = default; - return false; - } - } -} diff --git a/DomainModeling/Conversions/ObjectInstantiator.cs b/DomainModeling/Conversions/ObjectInstantiator.cs index 654dea8..eacae18 100644 --- a/DomainModeling/Conversions/ObjectInstantiator.cs +++ b/DomainModeling/Conversions/ObjectInstantiator.cs @@ -44,8 +44,12 @@ static ObjectInstantiator() /// Throws a for arrays, strings, and unbound generic types. /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static T Instantiate() { + if (typeof(T).IsValueType) + return default!; + return ConstructionFunction(); } } diff --git a/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs b/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs index f4c78ff..e4df4d4 100644 --- a/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs +++ b/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; +using Architect.DomainModeling; using Architect.DomainModeling.Conversions; /// diff --git a/DomainModeling/Conversions/ValueWrapperJsonConverter.cs b/DomainModeling/Conversions/ValueWrapperJsonConverter.cs index 5b7f56a..ace08d3 100644 --- a/DomainModeling/Conversions/ValueWrapperJsonConverter.cs +++ b/DomainModeling/Conversions/ValueWrapperJsonConverter.cs @@ -14,7 +14,7 @@ public sealed class ValueWrapperJsonConverter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue> : System.Text.Json.Serialization.JsonConverter - where TWrapper : ISerializableDomainObject + where TWrapper : IValueWrapper { private const string RequiresUnreferencedCodeMessage = "Serialization requires unreferenced code."; private const string RequiresDynamicCodeMessage = "Serialization requires dynamic code."; @@ -68,7 +68,7 @@ public sealed class LargeNumberValueWrapperJsonConverter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue> : System.Text.Json.Serialization.JsonConverter - where TWrapper : ISerializableDomainObject + where TWrapper : IValueWrapper where TValue : INumber, ISpanParsable, ISpanFormattable { private const string RequiresUnreferencedCodeMessage = "Serialization requires unreferenced code."; diff --git a/DomainModeling/Conversions/ValueWrapperNewtonsoftJsonConverter.cs b/DomainModeling/Conversions/ValueWrapperNewtonsoftJsonConverter.cs index 65b5d77..aa89777 100644 --- a/DomainModeling/Conversions/ValueWrapperNewtonsoftJsonConverter.cs +++ b/DomainModeling/Conversions/ValueWrapperNewtonsoftJsonConverter.cs @@ -10,7 +10,7 @@ public sealed class ValueWrapperNewtonsoftJsonConverter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue> : Newtonsoft.Json.JsonConverter - where TWrapper : ISerializableDomainObject + where TWrapper : IValueWrapper { private static readonly Type? NullableWrapperType = typeof(TWrapper).IsValueType ? typeof(Nullable<>).MakeGenericType(typeof(TWrapper)) @@ -49,7 +49,7 @@ public sealed class LargeNumberValueWrapperNewtonsoftJsonConverter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue> : Newtonsoft.Json.JsonConverter - where TWrapper : ISerializableDomainObject + where TWrapper : IValueWrapper where TValue : INumber, ISpanParsable, ISpanFormattable { private static readonly Type? NullableWrapperType = typeof(TWrapper).IsValueType diff --git a/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs b/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs index ed71376..0d24763 100644 --- a/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs +++ b/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using Architect.DomainModeling; using Architect.DomainModeling.Conversions; /// diff --git a/DomainModeling/Conversions/ValueWrapperUnwrapper.cs b/DomainModeling/Conversions/ValueWrapperUnwrapper.cs new file mode 100644 index 0000000..190a9d6 --- /dev/null +++ b/DomainModeling/Conversions/ValueWrapperUnwrapper.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Architect.DomainModeling.Conversions; + +/// +/// +/// Exposes wrapping and unwrapping methods for instances. +/// +/// +/// Wrapping and unwrapping is the normal process of constructing value wrappers around values and extracting those values again. It may hit validation logic. +/// +/// +public static class ValueWrapperUnwrapper +{ + #region Wrap + + /// + /// Wraps a in a . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: NotNullIfNotNull(nameof(value))] + public static TWrapper? Wrap(TValue? value) + where TWrapper : IValueWrapper + { + return value is null + ? default + : TWrapper.Create(value); + } + + #endregion + + #region Unwrap + + /// + /// Unwraps the from a . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TValue? Unwrap( + TWrapper? instance) + where TWrapper : IValueWrapper + { + return instance is null + ? default + : instance.Value; + } + + #endregion +} diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index f421729..b53c1ab 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -34,23 +34,30 @@ Release notes: 4.0.0: -Platform support +Platform support: - BREAKING: Platform support: Dropped support for .NET 6.0 and .NET 7.0 (EOL). The base class is dead. Long live the interface! -Performance +Completed support for nested wrappers: +- Feature: Nested WrapperValueObject/Identity types can now also wrap/unwrap and serialize/deserialize directly to/from their core (deepest) underlying type. +- Feature: EF conversions for such types now automatically map to and from the core type. +- Feature: A deeper underlying type can be simulated, e.g. LazyStringId : IIdentity<Lazy<string>>, ICoreValueWrapper<LazyStringId, string>. +- BREAKING: ISerializableDomainObject is deprecated in favor of IValueWrapper, a clear and comprehensive type to represented generic value wrappers. +- BREAKING: IIdentityConfigurator and IWrapperValueObjectConfigurator now receive an additional type parameter on their methods, namely the core type. + +Performance: - Enhancement: Reduced assembly size by having source-generated WrapperValueObject/Identity types use generic JSON serializers instead of generating their own. -- Enhancement: Reduced assembly size by having source-generated WrapperValueObject/Identity types use default interface implementations and/or extension members instead of generating various components. -- Enhancement: Improved (compile-time) source generator performance. +- Enhancement: Improved source generator performance. -Misc improvements +Misc improvements: - Semi-breaking: IIdentity now implements IWrapperValueObject. - Semi-breaking: I[Utf8][Span]Formattable implementations based on strings have stopped treating null strings as "", as this could cover up mistakes instead of revealing them. - Bug fix: Fixed a bug where source-generated records would always generate ToString()/Equals()/GetHashCode(), even if you wrote your own. - Bug fix: Fixed a bug where source-generated WrapperValueObject/Identity types would not recognize manual member implementations if they were explicit interface implementations. -- Buf fix: Fixed a bug where the DummyBuilder generator struggled with nested types. +- Bug fix: Fixed a bug where the DummyBuilder generator struggled with nested types. - Bug fix: Fixed a bug where the analyzer would not properly warn that source generation on nested types is unsupported. +- Enhancement: Improved clarity of analyzer warnings, by stopping before subsequent problems occur. - Enhancement: Improved correctness of trimming. 3.0.3: diff --git a/DomainModeling/ISerializableDomainObject.cs b/DomainModeling/ISerializableDomainObject.cs index 0cae9b8..4836369 100644 --- a/DomainModeling/ISerializableDomainObject.cs +++ b/DomainModeling/ISerializableDomainObject.cs @@ -5,6 +5,7 @@ namespace Architect.DomainModeling; /// /// A domain object of type that can be serialized to and deserialized from underlying type . /// +[Obsolete("Use IValueWrapper instead.", error: true)] public interface ISerializableDomainObject< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying> diff --git a/DomainModeling/IValueWrapper.cs b/DomainModeling/IValueWrapper.cs new file mode 100644 index 0000000..529091b --- /dev/null +++ b/DomainModeling/IValueWrapper.cs @@ -0,0 +1,109 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Architect.DomainModeling; + +/// +/// +/// An instance of wrapping a value of type in a property named Value. +/// +/// +/// Supports wrapping and unwrapping (which may hit validation logic) as well as trusted serializing and deserializing (which avoids logic). +/// +/// +/// The wrapper type. +/// The type being wrapped. +public interface IValueWrapper + where TWrapper : IValueWrapper +{ + // Note that a struct implementation can always return a null value + TValue? Value { get; } + + /// + /// Constructs a new around the given . + /// + abstract static TWrapper Create( +#nullable disable // We are used interchangeably between types with nullable vs. non-nullable values, so do not enforce either + TValue value); +#nullable enable + + /// + /// Attempts to construct a new around the given . + /// + virtual static bool TryCreate( +#nullable disable // We are used interchangeably between types with nullable vs. non-nullable values, so do not enforce either + TValue value, +#nullable enable + [MaybeNullWhen(false)] out TWrapper result) + { + try + { + result = TWrapper.Create(value); + return true; + } + catch + { + result = default; + return false; + } + } + + /// + /// + /// Serializes the as a . + /// + /// + /// This provides the basis for concrete serialization, such as to JSON or for a database provider. + /// + /// + /// Domain model serialization is intended to work with trusted data and should skip validation and other logic. + /// + /// + TValue? Serialize(); + + /// + /// + /// Deserializes a from a . + /// + /// + /// This provides the basis for concrete deserialization, such as from JSON or from a database provider. + /// + /// + /// Domain model serialization is intended to work with trusted data and should skip validation and other logic. + /// + /// + abstract static TWrapper Deserialize(TValue value); +} + +/// +/// +/// An instance of wrapping a value of type in a property named Value. +/// +/// +/// This interface further marks as the direct underlying type. +/// A wrapper around another wrapper may implement repeatedly for various s, but should have only one . +/// +/// +/// Supports wrapping and unwrapping (which may hit validation logic) as well as trusted serializing and deserializing (which avoids logic). +/// +/// +public interface IDirectValueWrapper : IValueWrapper + where TWrapper : IDirectValueWrapper +{ +} + +/// +/// +/// An instance of wrapping a value of type in a property named Value. +/// +/// +/// This interface further marks as the core (deepest) underlying type. +/// A wrapper around another wrapper may implement repeatedly for various s, but should have only one . +/// +/// +/// Supports wrapping and unwrapping (which may hit validation logic) as well as trusted serializing and deserializing (which avoids logic). +/// +/// +public interface ICoreValueWrapper : IValueWrapper + where TWrapper : ICoreValueWrapper +{ +} From 98ca4e92fa558f4a4b57568c157c7a16d4c6e321 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Mon, 8 Sep 2025 21:06:38 +0200 Subject: [PATCH 11/23] Added wrapper EF collations, collation checks, and provider comparers. --- .../EntityFrameworkConfigurationGenerator.cs | 457 ++++++++++++++++-- .../Common/CapturingLoggerProvider.cs | 38 ++ ...ityFrameworkConfigurationGeneratorTests.cs | 71 ++- .../Configuration/IIdentityConfigurator.cs | 1 - .../IWrapperValueObjectConfigurator.cs | 1 - .../IdentityConfigurationOptions.cs | 5 + .../ValueWrapperConfigurationOptions.cs | 27 ++ .../WrapperValueObjectConfigurationOptions.cs | 5 + DomainModeling/DomainModeling.csproj | 8 +- README.md | 28 +- 10 files changed, 595 insertions(+), 46 deletions(-) create mode 100644 DomainModeling.Tests/Common/CapturingLoggerProvider.cs create mode 100644 DomainModeling/Configuration/IdentityConfigurationOptions.cs create mode 100644 DomainModeling/Configuration/ValueWrapperConfigurationOptions.cs create mode 100644 DomainModeling/Configuration/WrapperValueObjectConfigurationOptions.cs diff --git a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs index 5987b80..78ad4fa 100644 --- a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs +++ b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs @@ -122,10 +122,10 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var ownAssemblyName = input.AssemblyName; var identityConfigurationCalls = String.Join( - $"{Environment.NewLine}\t\t\t", + $"{Environment.NewLine}\t\t\t\t", input.Generatable.ReferencedAssembliesWithIdentityConfigurator!.Value.Select(assemblyName => $"{assemblyName}.IdentityDomainModelConfigurator.ConfigureIdentities(concreteConfigurator);")); var wrapperValueObjectConfigurationCalls = String.Join( - $"{Environment.NewLine}\t\t\t", + $"{Environment.NewLine}\t\t\t\t", input.Generatable.ReferencedAssembliesWithWrapperValueObjectConfigurator!.Value.Select(assemblyName => $"{assemblyName}.WrapperValueObjectDomainModelConfigurator.ConfigureWrapperValueObjects(concreteConfigurator);")); var entityConfigurationCalls = String.Join( $"{Environment.NewLine}\t\t\t", @@ -141,13 +141,20 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System.Reflection; using System.Runtime.CompilerServices; using Architect.DomainModeling; +using Architect.DomainModeling.Configuration; using Architect.DomainModeling.Conversions; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; #nullable enable @@ -170,18 +177,42 @@ public static ModelConfigurationBuilder ConfigureDomainModelConventions(this Mod /// Configures conventions for all marked types. /// /// - /// This configures conversions to and from the underlying type for properties of the identity types. + /// This configures conversions to and from the core type for properties of 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. + /// Additionally, -backed identities receive a provider value comparer matching their own s. + /// This is important because Entity Framework performs all comparisons of keys on the core (provider) values. + /// This method also warns if the collation (or the provider's default) mismatches the type's . + /// + /// + /// 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) + /// If given, the method also applies any options specified. + public static IDomainModelConfigurator ConfigureIdentityConventions(this IDomainModelConfigurator configurator, IdentityConfigurationOptions? options = null) {{ - var concreteConfigurator = new EntityFrameworkIdentityConfigurator(configurator.ConfigurationBuilder); + // Apply Identity configuration (immediate) + {{ + var concreteConfigurator = new EntityFrameworkIdentityConfigurator(configurator.ConfigurationBuilder, options); + // Call configurator for each Identity type + {identityConfigurationCalls} + }} - {identityConfigurationCalls} + // Apply common ValueWrapper configuration (deferred) + {{ + ValueWrapperConfigurator concreteConfigurator = null!; + configurator.ConfigurationBuilder.Conventions.Add(serviceProvider => concreteConfigurator = new ValueWrapperConfigurator( + configurator.ConfigurationBuilder, + serviceProvider.GetRequiredService>(), + serviceProvider.GetService(), + () => + {{ + // Call configurator for each Identity type + {identityConfigurationCalls} + }}, + options)); + }} return configurator; }} @@ -191,15 +222,39 @@ public static IDomainModelConfigurator ConfigureIdentityConventions(this IDomain /// Configures conventions for all marked types. /// /// - /// This configures conversions to and from the underlying type for properties of the wrapper types. + /// This configures conversions to and from the core 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. /// + /// + /// Additionally, -backed wrappers receive a provider value comparer matching their own s. + /// This is important because Entity Framework performs all comparisons of keys on the core (provider) values. + /// This method also warns if the collation (or the provider's default) mismatches the type's . + /// /// - public static IDomainModelConfigurator ConfigureWrapperValueObjectConventions(this IDomainModelConfigurator configurator) + /// If given, the method also applies any options specified. + public static IDomainModelConfigurator ConfigureWrapperValueObjectConventions(this IDomainModelConfigurator configurator, WrapperValueObjectConfigurationOptions? options = null) {{ - var concreteConfigurator = new EntityFrameworkWrapperValueObjectConfigurator(configurator.ConfigurationBuilder); + // Apply WrapperValueObject configuration (immediate) + {{ + var concreteConfigurator = new EntityFrameworkWrapperValueObjectConfigurator(configurator.ConfigurationBuilder, options); + // Call configurator for each WrapperValueObject type + {wrapperValueObjectConfigurationCalls} + }} - {wrapperValueObjectConfigurationCalls} + // Apply common ValueWrapper configuration (deferred) + {{ + ValueWrapperConfigurator concreteConfigurator = null!; + configurator.ConfigurationBuilder.Conventions.Add(serviceProvider => concreteConfigurator = new ValueWrapperConfigurator( + configurator.ConfigurationBuilder, + serviceProvider.GetRequiredService>(), + serviceProvider.GetService(), + () => + {{ + // Call configurator for each WrapperValueObject type + {wrapperValueObjectConfigurationCalls} + }}, + options)); + }} return configurator; }} @@ -217,9 +272,11 @@ public static IDomainModelConfigurator ConfigureEntityConventions(this IDomainMo EntityFrameworkEntityConfigurator concreteConfigurator = null!; concreteConfigurator = new EntityFrameworkEntityConfigurator(() => {{ + // Call configurator for each Entity type {entityConfigurationCalls} }}); + // Apply Entity configuration (deferred) configurator.ConfigurationBuilder.Conventions.Add(_ => concreteConfigurator); return configurator; @@ -238,13 +295,51 @@ public static IDomainModelConfigurator ConfigureDomainEventConventions(this IDom EntityFrameworkEntityConfigurator concreteConfigurator = null!; concreteConfigurator = new EntityFrameworkEntityConfigurator(() => {{ + // Call configurator for each DomainEvent type {domainEventConfigurationCalls} }}); + // Apply DomainEvent configuration (deferred) configurator.ConfigurationBuilder.Conventions.Add(_ => concreteConfigurator); return configurator; }} + + /// + /// + /// Configures custom conventions on marked types, via a simple callback per type. + /// + /// + /// For example, configure every identity type wrapping a to have a max length, fixed length, and collation. + /// + /// + /// To receive generic callbacks instead, create a concrete implementation of , and use to initiate the callbacks to its generic method. + /// + /// + public static IDomainModelConfigurator CustomizeIdentityConventions(this IDomainModelConfigurator configurator, Action callback) + {{ + var concreteConfigurator = new CustomizingIdentityConfigurator(configurator.ConfigurationBuilder, callback); + IdentityDomainModelConfigurator.ConfigureIdentities(concreteConfigurator); + return configurator; + }} + + /// + /// + /// Configures custom conventions on marked types, via a simple callback per type. + /// + /// + /// For example, configure every wrapper type wrapping a to have a certain precision. + /// + /// + /// To receive generic callbacks instead, create a concrete implementation of , and use to initiate the callbacks to its generic method. + /// + /// + public static IDomainModelConfigurator CustomizeWrapperValueObjectConventions(this IDomainModelConfigurator configurator, Action callback) + {{ + var concreteConfigurator = new CustomizingWrapperValueObjectConfigurator(configurator.ConfigurationBuilder, callback); + WrapperValueObjectDomainModelConfigurator.ConfigureWrapperValueObjects(concreteConfigurator); + return configurator; + }} }} public interface IDomainModelConfigurator @@ -256,50 +351,252 @@ file sealed record class DomainModelConfigurator( ModelConfigurationBuilder ConfigurationBuilder) : IDomainModelConfigurator; - file sealed record class EntityFrameworkIdentityConfigurator(ModelConfigurationBuilder ConfigurationBuilder) - : Architect.DomainModeling.Configuration.IIdentityConfigurator + file sealed record class ValueWrapperConfigurator( + ModelConfigurationBuilder ConfigurationBuilder, + IDiagnosticsLogger DiagnosticLogger, + IDatabaseProvider? DatabaseProvider, + Action InvokeConfigurationCallbacks, + ValueWrapperConfigurationOptions? Options) + : IIdentityConfigurator, IWrapperValueObjectConfigurator, IModelInitializedConvention, IModelFinalizingConvention + {{ + private Dictionary DesiredCaseSensitivityPerType {{ get; }} = []; + + internal static bool IsStringWrapperWithKnownCaseSensitivity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TCore>( + [NotNullWhen(true)] out StringComparison caseSensitivity) + where TWrapper : ICoreValueWrapper + {{ + caseSensitivity = default; + if (typeof(TCore) != typeof(string) || GaugeDesiredCaseSensitivity() is not {{ }} value) + return false; + caseSensitivity = value; + return true; + }} + + internal static string? GetApplicableCollationFromOptions(StringComparison caseSensitivity, ValueWrapperConfigurationOptions? options) + {{ + return caseSensitivity switch + {{ + StringComparison.Ordinal when options?.CaseSensitiveCollation is {{ }} collation => collation, + StringComparison.OrdinalIgnoreCase when options?.IgnoreCaseCollation is {{ }} collation => collation, + _ => null, + }}; + }} + + public void ProcessModelInitialized(IConventionModelBuilder modelBuilder, IConventionContext context) + {{ + this.InvokeConfigurationCallbacks(); + }} + + public void ConfigureIdentity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, TUnderlying, TCore>( + in IIdentityConfigurator.Args args) + where TIdentity : IIdentity, IDirectValueWrapper, ICoreValueWrapper + where TUnderlying : notnull, IEquatable, IComparable + {{ + this.ApplyConfiguration(); + }} + + public void ConfigureWrapperValueObject<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue, TCore>( + in IWrapperValueObjectConfigurator.Args args) + where TWrapper : IWrapperValueObject, IDirectValueWrapper, ICoreValueWrapper + where TValue : notnull + {{ + this.ApplyConfiguration(); + }} + + private void ApplyConfiguration<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TCore>() + where TWrapper : ICoreValueWrapper + {{ + // For string wrappers where we can ascertain the desired case-sensitivity + if (IsStringWrapperWithKnownCaseSensitivity(out var caseSensitivity)) + {{ + // Remember the case-sensitivity to use in model finalizing + this.DesiredCaseSensitivityPerType[typeof(TWrapper)] = caseSensitivity; + + // Log the collation set by the Identity/WrapperValueObject configurator, which needed to set this before user code, without waiting for access to a logger, so that user code could still override + if (GetApplicableCollationFromOptions(caseSensitivity, this.Options) is string collation && this.DiagnosticLogger.Logger.IsEnabled(LogLevel.Debug)) + this.DiagnosticLogger.Logger.LogDebug(""Set collation {{TargetCollation}} for {{WrapperType}} properties based on the type's case-sensitivity"", collation, typeof(TWrapper).Name); + }} + }} + + public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) + {{ + var providerDefaultCaseSensitivity = GaugeProviderDefaultCaseSensitivity(this.DatabaseProvider?.Name, out var providerFriendlyName); + + foreach (var property in modelBuilder.Metadata.GetEntityTypes().SelectMany(entityBuilder => entityBuilder.GetProperties())) + {{ + // We only care about values mapped to strings, and only where they are wrapper types that we know the desired case-sensitivity for + if (property.GetValueConverter()?.ProviderClrType != typeof(string) || !this.DesiredCaseSensitivityPerType.TryGetValue(property.ClrType, out var desiredCaseSensitivity)) + continue; + + // If the database's behavior mismatches the model's behavior, then warn + var actualCaseSensitivity = GaugeCaseSensitivity(property, providerDefaultCaseSensitivity, providerFriendlyName, out var collationIndicator, out var isDeliberateChoice); + if (actualCaseSensitivity is not null && actualCaseSensitivity != desiredCaseSensitivity && !isDeliberateChoice && this.DiagnosticLogger.Logger.IsEnabled(LogLevel.Warning)) + {{ + this.DiagnosticLogger.Logger.LogWarning( + ""{{Entity}}.{{Property}} uses {{DesiredCaseSensitivity}} comparisons, but the {{collationIndicator}} database collation acts more like {{ActualCaseSensitivity}} - use the options in ConfigureIdentityConventions() and ConfigureWrapperValueObjectConventions() to specify default collations, or configure property collations manually"", + property.DeclaringType.Name, + property.Name, + desiredCaseSensitivity, + collationIndicator, + actualCaseSensitivity); + }} + }} + + this.DesiredCaseSensitivityPerType.Clear(); + this.DesiredCaseSensitivityPerType.TrimExcess(); + }} + + private static StringComparison? GaugeDesiredCaseSensitivity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TCore>() + where TWrapper : ICoreValueWrapper + {{ + System.Diagnostics.Debug.Assert(typeof(TCore) == typeof(string), ""This method is intended only for string wrappers.""); + try + {{ + var comparisonResult = EqualityComparer.Default.Equals( + DomainObjectSerializer.Deserialize((TCore)(object)""A""), + DomainObjectSerializer.Deserialize((TCore)(object)""a"")); + return comparisonResult + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + }} + catch + {{ + return null; + }} + }} + + /// + /// Gauges the case-sensitivity of the given 's collation, with fallback to the given . + /// Returns null if the case-sensitivity cannot be determined. + /// + /// Deliberate choices may warrant permitting discrepancies, whereas accidental discrepancies are cause for alarm. + private static StringComparison? GaugeCaseSensitivity( + IConventionProperty property, + StringComparison? providerDefaultCaseSensitivity, string providerFriendlyName, + out string? collationIndicator, out bool isDeliberateChoice) + {{ + collationIndicator = property.GetCollation(); + isDeliberateChoice = true; + if (collationIndicator is null) + {{ + collationIndicator = property.DeclaringType.Model.GetCollation(); + isDeliberateChoice = false; + }} + var result = GaugeCaseSensitivity(collationIndicator); + if (result is null) + {{ + result = providerDefaultCaseSensitivity; + collationIndicator = $""default {{providerFriendlyName}}""; + isDeliberateChoice = false; + }} + return result; + }} + + /// + /// Gauges the case-sensitivity of the given , or null if we cannot determine one. + /// The implementation is familiar with: [Azure] SQL Server, PostgreSQL, MySQL, SQLite. + /// + private static StringComparison? GaugeCaseSensitivity(string? collationName) + {{ + var collationNameSpan = collationName.AsSpan(); + return collationNameSpan switch + {{ + _ when collationNameSpan.Contains(""_BIN"", StringComparison.OrdinalIgnoreCase) => StringComparison.Ordinal, // SQL Server, MySQL + _ when collationNameSpan.Contains(""_CS"", StringComparison.OrdinalIgnoreCase) => StringComparison.Ordinal, // SQL Server, MySQL + _ when collationNameSpan.Contains(""BINARY"", StringComparison.OrdinalIgnoreCase) => StringComparison.Ordinal, // SQLite + _ when collationNameSpan.Contains(""_CI"", StringComparison.OrdinalIgnoreCase) => StringComparison.OrdinalIgnoreCase, // SQL Server, MySQL + _ when collationNameSpan.Contains(""NOCASE"", StringComparison.OrdinalIgnoreCase) => StringComparison.OrdinalIgnoreCase, // SQLite + _ when collationNameSpan.Contains(""ks-level1"", StringComparison.OrdinalIgnoreCase) => StringComparison.OrdinalIgnoreCase, // Postgres + _ when collationNameSpan.Contains(""ks-primary"", StringComparison.OrdinalIgnoreCase) => StringComparison.OrdinalIgnoreCase, // Postgres + _ => null, + }}; + }} + + /// + /// Gauges the default case-sensitivity of the given , or null if we cannot determine one. + /// The implementation is familiar with: [Azure] SQL Server, PostgreSQL, MySQL, SQLite. + /// + private static StringComparison? GaugeProviderDefaultCaseSensitivity(string? providerName, out string providerFriendlyName) + {{ + var providerNameSpan = providerName.AsSpan(); + var (result, friendlyName) = providerNameSpan switch + {{ + _ when providerNameSpan.Contains(""SQLServer"", StringComparison.OrdinalIgnoreCase) => (StringComparison.OrdinalIgnoreCase, ""SQL Server""), + _ when providerNameSpan.Contains(""MySQL"", StringComparison.OrdinalIgnoreCase) => (StringComparison.OrdinalIgnoreCase, ""MySQL""), + _ when providerNameSpan.Contains(""Postgres"", StringComparison.OrdinalIgnoreCase) => (StringComparison.Ordinal, ""PostgreSQL""), + _ when providerNameSpan.Contains(""npgsql"", StringComparison.OrdinalIgnoreCase) => (StringComparison.Ordinal, ""PostgreSQL""), + _ when providerNameSpan.Contains(""SQLite"", StringComparison.OrdinalIgnoreCase) => (StringComparison.Ordinal, ""SQLite""), + _ => ((StringComparison?)null, ""unknown""), + }}; + providerFriendlyName = friendlyName; + return result; + }} + }} + + file sealed record class EntityFrameworkIdentityConfigurator( + ModelConfigurationBuilder ConfigurationBuilder, + IdentityConfigurationOptions? Options = null) + : IIdentityConfigurator {{ private static readonly ConverterMappingHints DecimalIdConverterMappingHints = new ConverterMappingHints(precision: 28, scale: 0); // For decimal IDs public void ConfigureIdentity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, TUnderlying, TCore>( - in Architect.DomainModeling.Configuration.IIdentityConfigurator.Args args) + in IIdentityConfigurator.Args args) where TIdentity : IIdentity, IDirectValueWrapper, ICoreValueWrapper where TUnderlying : notnull, IEquatable, IComparable {{ // Configure properties of the type this.ConfigurationBuilder.Properties() - .HaveConversion>(); + .HaveConversion>(); // Configure non-property occurrences of the type, such as in CAST(), SUM(), AVG(), etc. this.ConfigurationBuilder.DefaultTypeMapping() - .HasConversion>(); + .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(TCore) == typeof(decimal)) this.ConfigurationBuilder.DefaultTypeMapping() .HasPrecision(28, 0); + + // For string wrappers where we can ascertain the desired case-sensitivity + if (ValueWrapperConfigurator.IsStringWrapperWithKnownCaseSensitivity(out var caseSensitivity)) + {{ + var comparerType = caseSensitivity switch + {{ + StringComparison.Ordinal => typeof(OrdinalStringComparer), + StringComparison.OrdinalIgnoreCase => typeof(OrdinalIgnoreCaseStringComparer), + _ => null, + }}; + this.ConfigurationBuilder.Properties() + .HaveConversion(conversionType: typeof(IdentityConverter), comparerType: null, providerComparerType: comparerType); + + if (ValueWrapperConfigurator.GetApplicableCollationFromOptions(caseSensitivity, this.Options) is string targetCollation) + this.ConfigurationBuilder.Properties() + .UseCollation(targetCollation); + }} }} - private sealed class IdentityValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> + private sealed class IdentityConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> : ValueConverter where TModel : IValueWrapper {{ - public IdentityValueObjectConverter() + public IdentityConverter() : base( model => DomainObjectSerializer.Serialize(model)!, provider => DomainObjectSerializer.Deserialize(provider)!, - typeof(TProvider) == typeof(decimal) ? DecimalIdConverterMappingHints : null) + mappingHints: typeof(TProvider) == typeof(decimal) ? EntityFrameworkIdentityConfigurator.DecimalIdConverterMappingHints : null) {{ }} }} }} file sealed record class EntityFrameworkWrapperValueObjectConfigurator( - ModelConfigurationBuilder ConfigurationBuilder) - : Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator + ModelConfigurationBuilder ConfigurationBuilder, + WrapperValueObjectConfigurationOptions? Options = null) + : IWrapperValueObjectConfigurator {{ public void ConfigureWrapperValueObject<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue, TCore>( - in Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator.Args args) + in IWrapperValueObjectConfigurator.Args args) where TWrapper : IWrapperValueObject, IDirectValueWrapper, ICoreValueWrapper where TValue : notnull {{ @@ -310,6 +607,23 @@ file sealed record class EntityFrameworkWrapperValueObjectConfigurator( // Configure non-property occurrences of the type, such as in CAST(), SUM(), AVG(), etc. this.ConfigurationBuilder.DefaultTypeMapping() .HasConversion>(); + + // For string wrappers where we can ascertain the desired case-sensitivity + if (ValueWrapperConfigurator.IsStringWrapperWithKnownCaseSensitivity(out var caseSensitivity)) + {{ + var comparerType = caseSensitivity switch + {{ + StringComparison.Ordinal => typeof(OrdinalStringComparer), + StringComparison.OrdinalIgnoreCase => typeof(OrdinalIgnoreCaseStringComparer), + _ => null, + }}; + this.ConfigurationBuilder.Properties() + .HaveConversion(conversionType: typeof(WrapperValueObjectConverter), comparerType: null, providerComparerType: comparerType); + + if (ValueWrapperConfigurator.GetApplicableCollationFromOptions(caseSensitivity, this.Options) is string targetCollation) + this.ConfigurationBuilder.Properties() + .UseCollation(targetCollation); + }} }} private sealed class WrapperValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> @@ -319,7 +633,8 @@ private sealed class WrapperValueObjectConverter<[DynamicallyAccessedMembers(Dyn public WrapperValueObjectConverter() : base( model => DomainObjectSerializer.Serialize(model)!, - provider => DomainObjectSerializer.Deserialize(provider)!) + provider => DomainObjectSerializer.Deserialize(provider)!, + mappingHints: null) {{ }} }} @@ -327,9 +642,9 @@ public WrapperValueObjectConverter() file sealed record class EntityFrameworkEntityConfigurator( Action InvokeConfigurationCallbacks) - : Architect.DomainModeling.Configuration.IEntityConfigurator, Architect.DomainModeling.Configuration.IDomainEventConfigurator, IEntityTypeAddedConvention, IModelFinalizingConvention + : IEntityConfigurator, IDomainEventConfigurator, IEntityTypeAddedConvention, IModelFinalizingConvention {{ - private Dictionary EntityTypeConventionsByType {{ get; }} = new Dictionary(); + private Dictionary EntityTypeConventionsByType {{ get; }} = []; public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilder, IConventionContext context) {{ @@ -341,10 +656,14 @@ public void ProcessEntityTypeAdded(IConventionEntityTypeBuilder entityTypeBuilde public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) {{ this.InvokeConfigurationCallbacks(); + + // Clean up + this.EntityTypeConventionsByType.Clear(); + this.EntityTypeConventionsByType.TrimExcess(); }} public void ConfigureEntity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TEntity>( - in Architect.DomainModeling.Configuration.IEntityConfigurator.Args args) + in IEntityConfigurator.Args args) where TEntity : IEntity {{ if (!this.EntityTypeConventionsByType.TryGetValue(typeof(TEntity), out var entityTypeConvention)) @@ -357,7 +676,7 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven }} public void ConfigureDomainEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TDomainEvent>( - in Architect.DomainModeling.Configuration.IDomainEventConfigurator.Args args) + in IDomainEventConfigurator.Args args) where TDomainEvent : IDomainObject {{ if (!this.EntityTypeConventionsByType.TryGetValue(typeof(TDomainEvent), out var entityTypeConvention)) @@ -372,6 +691,7 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven private sealed class UninitializedInstantiationBinding : InstantiationBinding {{ + [SuppressMessage(""Trimming"", ""IL2111:Method with DynamicallyAccessedMembersAttribute is accessed via reflection"", Justification = ""Fallback only, and we have annotated the input we take for this."")] private static readonly MethodInfo GetUninitializedObjectMethod = typeof(RuntimeHelpers).GetMethod(nameof(RuntimeHelpers.GetUninitializedObject))!; public override Type RuntimeType {{ get; }} @@ -406,6 +726,89 @@ public override InstantiationBinding With(IReadOnlyList parame }} }} }} + + file sealed class OrdinalStringComparer : ValueComparer + {{ + public OrdinalStringComparer() + : base( + equalsExpression: (left, right) => String.Equals(left, right, StringComparison.Ordinal), + hashCodeExpression: value => String.GetHashCode(value, StringComparison.Ordinal), + snapshotExpression: value => value) + {{ + }} + }} + + public sealed record class CustomizingIdentityConfigurator( + ModelConfigurationBuilder ConfigurationBuilder, + Action Callback) + : IIdentityConfigurator + {{ + public readonly struct Context + {{ + public ModelConfigurationBuilder ConfigurationBuilder {{ get; init; }} + public Type ModelType {{ get; init; }} + public Type UnderlyingType {{ get; init; }} + public Type CoreType {{ get; init; }} + public IIdentityConfigurator.Args Args {{ get; init; }} + }} + + public void ConfigureIdentity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, TUnderlying, TCore>( + in IIdentityConfigurator.Args args) + where TIdentity : IIdentity, IDirectValueWrapper, ICoreValueWrapper + where TUnderlying : notnull, IEquatable, IComparable + {{ + var customizationArgs = new Context() + {{ + ConfigurationBuilder = this.ConfigurationBuilder, + ModelType = typeof(TIdentity), + UnderlyingType = typeof(TUnderlying), + CoreType = typeof(TCore), + Args = args, + }}; + this.Callback.Invoke(customizationArgs); + }} + }} + + public sealed record class CustomizingWrapperValueObjectConfigurator( + ModelConfigurationBuilder ConfigurationBuilder, + Action Callback) + : IWrapperValueObjectConfigurator + {{ + public readonly struct Context + {{ + public ModelConfigurationBuilder ConfigurationBuilder {{ get; init; }} + public Type ModelType {{ get; init; }} + public Type UnderlyingType {{ get; init; }} + public Type CoreType {{ get; init; }} + public IWrapperValueObjectConfigurator.Args Args {{ get; init; }} + }} + + public void ConfigureWrapperValueObject<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue, TCore>(in IWrapperValueObjectConfigurator.Args args) + where TWrapper : IWrapperValueObject, IDirectValueWrapper, ICoreValueWrapper + where TValue : notnull + {{ + var customizationArgs = new Context() + {{ + ConfigurationBuilder = this.ConfigurationBuilder, + ModelType = typeof(TWrapper), + UnderlyingType = typeof(TValue), + CoreType = typeof(TCore), + Args = args, + }}; + this.Callback.Invoke(customizationArgs); + }} + }} + + file sealed class OrdinalIgnoreCaseStringComparer : ValueComparer + {{ + public OrdinalIgnoreCaseStringComparer() + : base( + equalsExpression: (left, right) => String.Equals(left, right, StringComparison.OrdinalIgnoreCase), + hashCodeExpression: value => String.GetHashCode(value, StringComparison.OrdinalIgnoreCase), + snapshotExpression: value => value) + {{ + }} + }} }} "; diff --git a/DomainModeling.Tests/Common/CapturingLoggerProvider.cs b/DomainModeling.Tests/Common/CapturingLoggerProvider.cs new file mode 100644 index 0000000..3e4086d --- /dev/null +++ b/DomainModeling.Tests/Common/CapturingLoggerProvider.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Logging; + +namespace Architect.DomainModeling.Tests.Common; + +public sealed class CapturingLoggerProvider : ILoggerProvider +{ + public IReadOnlyList Logs => this._logs; + private readonly List _logs = []; + + public ILogger CreateLogger(string categoryName) + { + return new CapturingLogger(this._logs); + } + + public void Dispose() + { + } + + public sealed class CapturingLogger( + List logs) + : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + logs.Add($"[{logLevel}] {formatter(state, exception)}"); + } + } +} diff --git a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs index d73677d..abb434f 100644 --- a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs +++ b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs @@ -1,8 +1,12 @@ +using System.Diagnostics.CodeAnalysis; +using Architect.DomainModeling.Configuration; using Architect.DomainModeling.Conversions; +using Architect.DomainModeling.Tests.Common; using Architect.DomainModeling.Tests.IdentityTestTypes; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Microsoft.Extensions.Logging; using Xunit; namespace Architect.DomainModeling.Tests.EntityFramework; @@ -11,12 +15,20 @@ public sealed class EntityFrameworkConfigurationGeneratorTests : IDisposable { internal static bool AllowParameterizedConstructors = true; + private ILoggerFactory LoggerFactory { get; } + private CapturingLoggerProvider CapturingLoggerProvider { get; } + 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.CapturingLoggerProvider = new CapturingLoggerProvider(); + this.LoggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(options => options + .SetMinimumLevel(LogLevel.Debug) + .AddProvider(this.CapturingLoggerProvider)); + + this.DbContext = new TestDbContext($"DataSource={this.UniqueName};Mode=Memory;Cache=Shared;", this.LoggerFactory); this.DbContext.Database.OpenConnection(); } @@ -61,7 +73,7 @@ public void ConfigureConventions_WithAllExtensionsCalled_ShouldBeAbleToWorkWithA Assert.Equal(2, reloadedDomainEvent.Id); - Assert.Equal(2, reloadedEntity.Id.Value); + Assert.Equal("A", reloadedEntity.Id.Value); Assert.Equal("One", reloadedEntity.Values.One); Assert.Equal(2m, reloadedEntity.Values.Two); Assert.Equal(3, reloadedEntity.Values.Three.Value?.Value.Value); @@ -76,13 +88,32 @@ public void ConfigureConventions_WithAllExtensionsCalled_ShouldBeAbleToWorkWithA var providerClrTypeForStringWrapperWithCustomIntCore = mappingForStringWithCustomIntCore?.GetValueConverter()?.ProviderClrType; Assert.Equal("INTEGER", columnTypeForStringWrapperWithCustomIntCore); Assert.Equal(typeof(int), providerClrTypeForStringWrapperWithCustomIntCore); + + // Case-sensitivity should be honored, even during key comparisons + Assert.Same(reloadedEntity, this.DbContext.Set().Find(new EntityForEFId("a"))); + + // The database's collation should have been made ignore-case by our ConfigureIdentityConventions() options + Assert.Same(reloadedEntity, this.DbContext.Set().SingleOrDefault(x => x.Id == "a")); + + // The logs should warn that Wrapper1ForEF has a mismatching collation in the database + var logs = this.CapturingLoggerProvider.Logs; + var warning = Assert.Single(logs, log => log.StartsWith("[Warning]")); + Assert.Equal("[Warning] Architect.DomainModeling.Tests.EntityFramework.ValueObjectForEF.One uses OrdinalIgnoreCase comparisons, but the default SQLite database collation acts more like Ordinal - use the options in ConfigureIdentityConventions() and ConfigureWrapperValueObjectConventions() to specify default collations, or configure property collations manually", warning); + + // The logs should show that certain collations were set + Assert.Contains(logs, log => log.Equals("[Debug] Set collation BINARY for SomeStringId properties based on the type's case-sensitivity")); + Assert.Contains(logs, log => log.Equals("[Debug] Set collation NOCASE for EntityForEFId properties based on the type's case-sensitivity")); } } internal sealed class TestDbContext( - string connectionString) - : DbContext(new DbContextOptionsBuilder().UseSqlite(connectionString).Options) + string connectionString, ILoggerFactory loggerFactory) + : DbContext(new DbContextOptionsBuilder() + .UseLoggerFactory(loggerFactory) + .UseSqlite(connectionString).Options) { + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "Suppression is necessary.")] + [SuppressMessage("Usage", "CA2263:Prefer generic overload when type is known", Justification = "We have no generic info for types received from callbacks.")] protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { configurationBuilder.Conventions.Remove(); @@ -91,17 +122,23 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura configurationBuilder.ConfigureDomainModelConventions(domainModel => { - domainModel.ConfigureIdentityConventions(); + domainModel.ConfigureIdentityConventions(new IdentityConfigurationOptions() { CaseSensitiveCollation = "BINARY", IgnoreCaseCollation = "NOCASE", }); domainModel.ConfigureWrapperValueObjectConventions(); domainModel.ConfigureEntityConventions(); domainModel.ConfigureDomainEventConventions(); - }); - // For a wrapper whose core type EF does not support, overwriting the conventions with our own should work - configurationBuilder.Properties() - .HaveConversion(); - configurationBuilder.DefaultTypeMapping() - .HasConversion(); + domainModel.CustomizeWrapperValueObjectConventions(context => + { + // For a wrapper whose core type EF does not support, overwriting the conventions with our own should work + if (context.CoreType == typeof(Lazy)) + { + context.ConfigurationBuilder.Properties(context.ModelType) + .HaveConversion(typeof(LazyStringWrapperConverter)); + context.ConfigurationBuilder.DefaultTypeMapping(context.ModelType) + .HasConversion(typeof(LazyStringWrapperConverter)); + } + }); + }); } private class LazyStringWrapperConverter : ValueConverter @@ -168,8 +205,14 @@ public DomainEventForEF(DomainEventForEFId id, object ignored) [IdentityValueObject] public readonly partial record struct DomainEventForEFId; +[IdentityValueObject] +public partial record struct EntityForEFId +{ + private StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; +} + [Entity] -internal sealed class EntityForEF : Entity +internal sealed class EntityForEF : Entity { /// /// This lets us test if a constructor is used or not. @@ -179,7 +222,7 @@ internal sealed class EntityForEF : Entity public ValueObjectForEF Values { get; } public EntityForEF(ValueObjectForEF values) - : base(id: 2) + : base(id: "A") { if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors) throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors."); @@ -200,7 +243,7 @@ private EntityForEF() [WrapperValueObject] internal sealed partial class Wrapper1ForEF { - protected override StringComparison StringComparison => StringComparison.Ordinal; + protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; /// /// This lets us test if a constructor is used or not. diff --git a/DomainModeling/Configuration/IIdentityConfigurator.cs b/DomainModeling/Configuration/IIdentityConfigurator.cs index 27e5bd8..2f86b3e 100644 --- a/DomainModeling/Configuration/IIdentityConfigurator.cs +++ b/DomainModeling/Configuration/IIdentityConfigurator.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using Architect.DomainModeling.Conversions; namespace Architect.DomainModeling.Configuration; diff --git a/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs b/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs index 043b9bf..90d5d91 100644 --- a/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs +++ b/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using Architect.DomainModeling.Conversions; namespace Architect.DomainModeling.Configuration; diff --git a/DomainModeling/Configuration/IdentityConfigurationOptions.cs b/DomainModeling/Configuration/IdentityConfigurationOptions.cs new file mode 100644 index 0000000..3f7161d --- /dev/null +++ b/DomainModeling/Configuration/IdentityConfigurationOptions.cs @@ -0,0 +1,5 @@ +namespace Architect.DomainModeling.Configuration; + +public record class IdentityConfigurationOptions : ValueWrapperConfigurationOptions +{ +} diff --git a/DomainModeling/Configuration/ValueWrapperConfigurationOptions.cs b/DomainModeling/Configuration/ValueWrapperConfigurationOptions.cs new file mode 100644 index 0000000..55ba875 --- /dev/null +++ b/DomainModeling/Configuration/ValueWrapperConfigurationOptions.cs @@ -0,0 +1,27 @@ +namespace Architect.DomainModeling.Configuration; + +/// +/// Base options for value wrappers, used by both and . +/// +public abstract record class ValueWrapperConfigurationOptions +{ + /// + /// + /// If specified, this collation is set on configured types that use . + /// + /// + /// This helps the database column match the model's behavior. + /// + /// + public string? CaseSensitiveCollation { get; init; } + + /// + /// + /// If specified, this collation is set on configured types that use . + /// + /// + /// This helps the database column match the model's behavior. + /// + /// + public string? IgnoreCaseCollation { get; init; } +} diff --git a/DomainModeling/Configuration/WrapperValueObjectConfigurationOptions.cs b/DomainModeling/Configuration/WrapperValueObjectConfigurationOptions.cs new file mode 100644 index 0000000..326e20c --- /dev/null +++ b/DomainModeling/Configuration/WrapperValueObjectConfigurationOptions.cs @@ -0,0 +1,5 @@ +namespace Architect.DomainModeling.Configuration; + +public record class WrapperValueObjectConfigurationOptions : ValueWrapperConfigurationOptions +{ +} diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index b53c1ab..84ca05d 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -37,8 +37,6 @@ Release notes: Platform support: - BREAKING: Platform support: Dropped support for .NET 6.0 and .NET 7.0 (EOL). -The base class is dead. Long live the interface! - Completed support for nested wrappers: - Feature: Nested WrapperValueObject/Identity types can now also wrap/unwrap and serialize/deserialize directly to/from their core (deepest) underlying type. - Feature: EF conversions for such types now automatically map to and from the core type. @@ -46,6 +44,12 @@ Completed support for nested wrappers: - BREAKING: ISerializableDomainObject is deprecated in favor of IValueWrapper, a clear and comprehensive type to represented generic value wrappers. - BREAKING: IIdentityConfigurator and IWrapperValueObjectConfigurator now receive an additional type parameter on their methods, namely the core type. +Correct string comparisons with EF: +- Feature: ConfigureIdentityConventions()/ConfigureWrapperValueObjectConventions() now set a PROVIDER value comparer for each string wrapper property, matching the type's case-sensitivity. Since EF Core 7, EF compares keys using the provider type instead of the model type. +- Feature: ConfigureIdentityConventions()/ConfigureWrapperValueObjectConventions() now warn if a string wrapper property has a collation mismatching the type's case-sensitivity, unless collation was explicitly chosen. +- Feature: ConfigureIdentityConventions()/ConfigureWrapperValueObjectConventions() now take an optional "options" parameter, which allows specifying the respective collations for case-sensitive vs. ignore-case string wrappers. +- Feature: ConfigureDomainModelConventions() now has convenience extension methods CustomizeIdentityConventions()/CustomizeWrapperValueObjectConventions(), for easy custom conventions, such as based on the core underlying type. + Performance: - Enhancement: Reduced assembly size by having source-generated WrapperValueObject/Identity types use generic JSON serializers instead of generating their own. - Enhancement: Improved source generator performance. diff --git a/README.md b/README.md index 5c9be0c..e46e11d 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,8 @@ internal sealed class MyDbContext : DbContext { // Snip + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "Suppression is necessary.")] + [SuppressMessage("Usage", "CA2263:Prefer generic overload when type is known", Justification = "We have no generic info for types received from callbacks.")] 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 @@ -378,10 +380,33 @@ internal sealed class MyDbContext : DbContext configurationBuilder.ConfigureDomainModelConventions(domainModel => { + // Defaults domainModel.ConfigureIdentityConventions(); domainModel.ConfigureWrapperValueObjectConventions(); domainModel.ConfigureEntityConventions(); domainModel.ConfigureDomainEventConventions(); + + domainModel.CustomizeIdentityConventions(context => + { + // Example: Use fixed-length strings with a binary collation for all string IIdentities + if (context.CoreType == typeof(string)) + { + context.ConfigurationBuilder.Properties(context.ModelType) + .HaveMaxLength(16) + .AreFixedLength() + .UseCollation("Latin1_General_100_BIN2"); + } + }); + + domainModel.CustomizeWrapperValueObjectConventions(context => + { + // Example: Use DECIMAL(19, 9) for all decimal wrappers + if (context.CoreType == typeof(decimal)) + { + context.ConfigurationBuilder.Properties(context.ModelType) + .HavePrecision(19, 9); + } + }); }); } } @@ -389,9 +414,10 @@ internal sealed class MyDbContext : DbContext `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. +The `Customize*()` methods make it easy to specify your own conventions, such as for every identity or wrapper value object with a string at its core. 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. +Property-specific mappings are only needed where they are meaningful, such as the maximum length of a particular string property. Since only conventions are registered, regular mappings can override any part of the provided behavior. From 6c514de703b15874a855cea5d5ef9d6ee999eee1 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:28:54 +0200 Subject: [PATCH 12/23] Shortened release notes --- DomainModeling/DomainModeling.csproj | 51 ++++++---------------------- 1 file changed, 10 insertions(+), 41 deletions(-) diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 84ca05d..d68d24a 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -54,50 +54,19 @@ Performance: - Enhancement: Reduced assembly size by having source-generated WrapperValueObject/Identity types use generic JSON serializers instead of generating their own. - Enhancement: Improved source generator performance. -Misc improvements: +Misc: +- Semi-breaking: IFormattable & co for string wrappers have stopped treating null strings as "", as this could cover up mistakes instead of revealing them. - Semi-breaking: IIdentity now implements IWrapperValueObject. -- Semi-breaking: I[Utf8][Span]Formattable implementations based on strings have stopped treating null strings as "", as this could cover up mistakes instead of revealing them. -- Bug fix: Fixed a bug where source-generated records would always generate ToString()/Equals()/GetHashCode(), even if you wrote your own. -- Bug fix: Fixed a bug where source-generated WrapperValueObject/Identity types would not recognize manual member implementations if they were explicit interface implementations. -- Bug fix: Fixed a bug where the DummyBuilder generator struggled with nested types. -- Bug fix: Fixed a bug where the analyzer would not properly warn that source generation on nested types is unsupported. +- Feature: Non-generic Wrapper/Identity interfaces. +- Feature: Analyzer warns when '==' or similar operator implicitly casts some IValueObject to something else. This avoids accidentally comparing unrelated types. +- Fix: Fixed bug where source-generated records would always generate ToString()/Equals()/GetHashCode(), even if you wrote your own. +- Fix: Fixed bug where source-generated Wrappers/Identities would not recognize manual member implementations if they were explicit interface implementations. +- Fix: Fixed bug where DummyBuilder generator struggled with nested types. +- Fix: Fixed bug where "no source generation on nested type" warning would not show. +- Enhancement: Generated types now have the CompilerGeneratedAttribute. +- Enhancement: Generated struct Wrappers/Identities now generate NULLABLE comparison operators, to circumvent counterintuitive lifting behavior. - Enhancement: Improved clarity of analyzer warnings, by stopping before subsequent problems occur. - Enhancement: Improved correctness of trimming. - -3.0.3: - -- Enhancement: Upgraded package versions. - -3.0.2: - -- Bug fix. - -3.0.1: - -- Bug fix. - -3.0.0: - -- 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 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. -- 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 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. The Architect The Architect From d8c08d6d42ded45c58f3710f45b7a5e2d38e3be7 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:34:57 +0200 Subject: [PATCH 13/23] Added [CompilerGenerated] to all generated types. --- .../EntityFrameworkConfigurationGenerator.cs | 40 +++++++++++++------ .../DummyBuilderGenerator.cs | 3 +- DomainModeling.Generator/IdentityGenerator.cs | 2 +- .../ValueObjectGenerator.cs | 6 ++- .../WrapperValueObjectGenerator.cs | 2 +- 5 files changed, 36 insertions(+), 17 deletions(-) diff --git a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs index 78ad4fa..cfd77ee 100644 --- a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs +++ b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs @@ -160,6 +160,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable namespace {ownAssemblyName} {{ + [CompilerGenerated] public static class EntityFrameworkDomainModelConfigurationExtensions {{ /// @@ -347,10 +348,12 @@ public interface IDomainModelConfigurator ModelConfigurationBuilder ConfigurationBuilder {{ get; }} }} + [CompilerGenerated] file sealed record class DomainModelConfigurator( ModelConfigurationBuilder ConfigurationBuilder) : IDomainModelConfigurator; + [CompilerGenerated] file sealed record class ValueWrapperConfigurator( ModelConfigurationBuilder ConfigurationBuilder, IDiagnosticsLogger DiagnosticLogger, @@ -533,6 +536,7 @@ _ when providerNameSpan.Contains(""SQLite"", StringComparison.OrdinalIgnoreCase) }} }} + [CompilerGenerated] file sealed record class EntityFrameworkIdentityConfigurator( ModelConfigurationBuilder ConfigurationBuilder, IdentityConfigurationOptions? Options = null) @@ -576,6 +580,7 @@ file sealed record class EntityFrameworkIdentityConfigurator( }} }} + [CompilerGenerated] private sealed class IdentityConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> : ValueConverter where TModel : IValueWrapper @@ -590,6 +595,7 @@ public IdentityConverter() }} }} + [CompilerGenerated] file sealed record class EntityFrameworkWrapperValueObjectConfigurator( ModelConfigurationBuilder ConfigurationBuilder, WrapperValueObjectConfigurationOptions? Options = null) @@ -626,6 +632,7 @@ file sealed record class EntityFrameworkWrapperValueObjectConfigurator( }} }} + [CompilerGenerated] private sealed class WrapperValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> : ValueConverter where TModel : IValueWrapper @@ -640,6 +647,7 @@ public WrapperValueObjectConverter() }} }} + [CompilerGenerated] file sealed record class EntityFrameworkEntityConfigurator( Action InvokeConfigurationCallbacks) : IEntityConfigurator, IDomainEventConfigurator, IEntityTypeAddedConvention, IModelFinalizingConvention @@ -688,8 +696,8 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven #pragma warning restore EF1001 // Internal EF Core API usage }} - private sealed class UninitializedInstantiationBinding - : InstantiationBinding + [CompilerGenerated] + private sealed class UninitializedInstantiationBinding : InstantiationBinding {{ [SuppressMessage(""Trimming"", ""IL2111:Method with DynamicallyAccessedMembersAttribute is accessed via reflection"", Justification = ""Fallback only, and we have annotated the input we take for this."")] private static readonly MethodInfo GetUninitializedObjectMethod = typeof(RuntimeHelpers).GetMethod(nameof(RuntimeHelpers.GetUninitializedObject))!; @@ -727,6 +735,7 @@ public override InstantiationBinding With(IReadOnlyList parame }} }} + [CompilerGenerated] file sealed class OrdinalStringComparer : ValueComparer {{ public OrdinalStringComparer() @@ -738,11 +747,25 @@ public OrdinalStringComparer() }} }} + [CompilerGenerated] + file sealed class OrdinalIgnoreCaseStringComparer : ValueComparer + {{ + public OrdinalIgnoreCaseStringComparer() + : base( + equalsExpression: (left, right) => String.Equals(left, right, StringComparison.OrdinalIgnoreCase), + hashCodeExpression: value => String.GetHashCode(value, StringComparison.OrdinalIgnoreCase), + snapshotExpression: value => value) + {{ + }} + }} + + [CompilerGenerated] public sealed record class CustomizingIdentityConfigurator( ModelConfigurationBuilder ConfigurationBuilder, Action Callback) : IIdentityConfigurator {{ + [CompilerGenerated] public readonly struct Context {{ public ModelConfigurationBuilder ConfigurationBuilder {{ get; init; }} @@ -769,11 +792,13 @@ public readonly struct Context }} }} + [CompilerGenerated] public sealed record class CustomizingWrapperValueObjectConfigurator( ModelConfigurationBuilder ConfigurationBuilder, Action Callback) : IWrapperValueObjectConfigurator {{ + [CompilerGenerated] public readonly struct Context {{ public ModelConfigurationBuilder ConfigurationBuilder {{ get; init; }} @@ -798,17 +823,6 @@ public readonly struct Context this.Callback.Invoke(customizationArgs); }} }} - - file sealed class OrdinalIgnoreCaseStringComparer : ValueComparer - {{ - public OrdinalIgnoreCaseStringComparer() - : base( - equalsExpression: (left, right) => String.Equals(left, right, StringComparison.OrdinalIgnoreCase), - hashCodeExpression: value => String.GetHashCode(value, StringComparison.OrdinalIgnoreCase), - snapshotExpression: value => value) - {{ - }} - }} }} "; diff --git a/DomainModeling.Generator/DummyBuilderGenerator.cs b/DomainModeling.Generator/DummyBuilderGenerator.cs index f552ad2..93e4e90 100644 --- a/DomainModeling.Generator/DummyBuilderGenerator.cs +++ b/DomainModeling.Generator/DummyBuilderGenerator.cs @@ -282,6 +282,7 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Runtime.CompilerServices; #nullable disable @@ -296,7 +297,7 @@ namespace {containingNamespace} /// That way, if the constructor changes, only the builder needs to be adjusted, rather than lots of test methods. /// /// - /* Generated */ {type.DeclaredAccessibility.ToCodeString()} partial{(builder.IsRecord ? " record" : "")} class {typeName} + [CompilerGenerated] {type.DeclaredAccessibility.ToCodeString()} partial{(builder.IsRecord ? " record" : "")} class {typeName} {{ {joinedComponents} diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index eaf1ac0..3c30d02 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -530,7 +530,7 @@ namespace {containingNamespace} {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} {(hasIdentityValueObjectAttribute ? "" : $"[IdentityValueObject<{underlyingTypeFullyQualifiedName}>]")} - {(entityTypeName is null ? "/* Generated */ " : "")}{accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} : + [CompilerGenerated] {accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} : IIdentity<{underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}>, diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index 2b04072..b374cb0 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -263,13 +263,17 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Architect.DomainModeling; #nullable enable namespace {containingNamespace} {{ - /* Generated */ {type.DeclaredAccessibility.ToCodeString()} sealed partial{(isRecord ? " record" : "")} class {typeName} : ValueObject, IEquatable<{typeName}>{(isComparable ? "" : "/*")}, IComparable<{typeName}>{(isComparable ? "" : "*/")} + [CompilerGenerated] {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.SpecialType == SpecialType.System_String) ? @"protected sealed override StringComparison StringComparison => StringComparison.Ordinal;" diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index 5986ee8..1f4ea50 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -441,7 +441,7 @@ namespace {containingNamespace} {{ {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} - /* Generated */ {generatable.Accessibility.ToCodeString()} sealed partial{(generatable.IsRecord ? " record" : "")} class {typeName} : + [CompilerGenerated] {generatable.Accessibility.ToCodeString()} sealed partial{(generatable.IsRecord ? " record" : "")} class {typeName} : WrapperValueObject<{underlyingTypeFullyQualifiedName}>, IEquatable<{typeName}>, {(isComparable ? "" : "//")}IComparable<{typeName}>, From b73cd7c042c35b30c7a60fb25cad22ff2ebce5a6 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:15:16 +0200 Subject: [PATCH 14/23] Added Analyzer project, and analyzer for inadvertent comparisons based on implicit conversion. --- ...licitConversionOnBinaryOperatorAnalyzer.cs | 112 ++++++++++++++++++ .../DomainModeling.Analyzer.csproj | 29 +++++ .../DomainModeling.Example.csproj | 1 + ...ConversionOnBinaryOperatorAnalyzerTests.cs | 103 ++++++++++++++++ .../DomainModeling.Tests.csproj | 1 + DomainModeling.sln | 8 +- .../Architect.DomainModeling.targets | 5 + DomainModeling/DomainModeling.csproj | 12 ++ 8 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs create mode 100644 DomainModeling.Analyzer/DomainModeling.Analyzer.csproj create mode 100644 DomainModeling.Tests/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzerTests.cs create mode 100644 DomainModeling/Architect.DomainModeling.targets diff --git a/DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs new file mode 100644 index 0000000..854169e --- /dev/null +++ b/DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs @@ -0,0 +1,112 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Architect.DomainModeling.Analyzer.Analyzers; + +/// +/// Prevents accidental equality/comparison operator usage between unrelated types, where implicit conversions inadvertently make the operation compile. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ValueObjectImplicitConversionOnBinaryOperatorAnalyzer : DiagnosticAnalyzer +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] + private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( + id: "ComparisonBetweenUnrelatedValueObjects", + title: "Comparison between unrelated value objects", + messageFormat: "Possible unintended '{0}' comparison between unrelated value objects {1} and {2}. Either compare value objects of the same type, implement a dedicated operator overload, or compare underlying values directly.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction( + AnalyzeBinaryExpression, + SyntaxKind.EqualsExpression, + SyntaxKind.NotEqualsExpression, + SyntaxKind.LessThanExpression, + SyntaxKind.LessThanOrEqualExpression, + SyntaxKind.GreaterThanExpression, + SyntaxKind.GreaterThanOrEqualExpression); + } + + private static void AnalyzeBinaryExpression(SyntaxNodeAnalysisContext context) + { + if (context.Node is not BinaryExpressionSyntax binaryExpression) + return; + + var semanticModel = context.SemanticModel; + var cancellationToken = context.CancellationToken; + + var leftTypeInfo = semanticModel.GetTypeInfo(binaryExpression.Left, cancellationToken); + var rightTypeInfo = semanticModel.GetTypeInfo(binaryExpression.Right, cancellationToken); + + // Not if either operand is typeless (e.g. null) + if (leftTypeInfo.Type is null || rightTypeInfo.Type is null) + return; + + // If either operand was implicitly converted FROM some IValueObject to something else, then the comparison is ill-advised + if (OperandWasImplicitlyConvertedFromIValueObject(leftTypeInfo) || OperandWasImplicitlyConvertedFromIValueObject(rightTypeInfo)) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptor, + context.Node.GetLocation(), + binaryExpression.OperatorToken.ValueText, + IsNullable(leftTypeInfo.Type, out var nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : leftTypeInfo.Type.Name, + IsNullable(rightTypeInfo.Type, out nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : rightTypeInfo.Type.Name); + + context.ReportDiagnostic(diagnostic); + } + } + + private static bool OperandWasImplicitlyConvertedFromIValueObject(TypeInfo operandTypeInfo) + { + var from = operandTypeInfo.Type; + var to = operandTypeInfo.ConvertedType; + + // If no type available or no implicit conversion took place, return false + if (from is null || from.Equals(to, SymbolEqualityComparer.Default)) + return false; + + // Do not flag nullable lifting (where a nullable and a non-nullable are compared) + // Note that it LOOKS as though the nullable is converted to non-nullable, but the opposite is true + if (IsNullable(to, out var nullableUnderlyingType) && nullableUnderlyingType.Equals(from, SymbolEqualityComparer.Default)) + return false; + + // Dig through nullables + if (IsNullable(from, out nullableUnderlyingType)) + from = nullableUnderlyingType; + if (IsNullable(to, out nullableUnderlyingType)) + to = nullableUnderlyingType; + + // Backwards compatibility: If converting to ValueObject, then ignore, because the ValueObject base class implements ==(ValueObject, ValueObject) + if (to is { Name: "ValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } }) + return false; + + var isConvertedFromIValueObject = from.AllInterfaces.Any(interf => + interf is { Name: "IValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } }); + + return isConvertedFromIValueObject; + } + + private static bool IsNullable(ITypeSymbol? potentialNullable, out ITypeSymbol underlyingType) + { + if (potentialNullable is not INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } namedTypeSymbol) + { + underlyingType = null!; + return false; + } + + underlyingType = namedTypeSymbol.TypeArguments[0]; + return true; + } +} diff --git a/DomainModeling.Analyzer/DomainModeling.Analyzer.csproj b/DomainModeling.Analyzer/DomainModeling.Analyzer.csproj new file mode 100644 index 0000000..6afff14 --- /dev/null +++ b/DomainModeling.Analyzer/DomainModeling.Analyzer.csproj @@ -0,0 +1,29 @@ + + + + netstandard2.0 + Architect.DomainModeling.Analyzer + Architect.DomainModeling.Analyzer + Enable + Enable + 13 + False + True + True + + + + + IDE0057 + + + + + + + + + + + + diff --git a/DomainModeling.Example/DomainModeling.Example.csproj b/DomainModeling.Example/DomainModeling.Example.csproj index 664335d..6747404 100644 --- a/DomainModeling.Example/DomainModeling.Example.csproj +++ b/DomainModeling.Example/DomainModeling.Example.csproj @@ -19,6 +19,7 @@ + diff --git a/DomainModeling.Tests/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzerTests.cs b/DomainModeling.Tests/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzerTests.cs new file mode 100644 index 0000000..1d67c29 --- /dev/null +++ b/DomainModeling.Tests/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzerTests.cs @@ -0,0 +1,103 @@ +using System.Diagnostics.CodeAnalysis; +using Architect.DomainModeling.Tests.IdentityTestTypes; +using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; + +namespace Architect.DomainModeling.Tests.Analyzers; + +[SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] +[SuppressMessage("Usage", "ComparisonBetweenUnrelatedValueObjects:Comparison between unrelated value objects", Justification = "Testing presence of warning.")] +public class ValueObjectImplicitConversionOnBinaryOperatorAnalyzerTests +{ + // Unfortunately, we always get "unnecessary suppression" even when the warning is successfully suppressed + // All we can do is manually outcomment the suppression temporarily to check that each statement in this file still warns + + public static void CompareUnrelatedIdentities_Always_ShouldWarn() + { + _ = (IntId)0 == (FullySelfImplementedIdentity)0; + _ = (IntId)0 != (FullySelfImplementedIdentity)0; + _ = (IntId)0 < (FullySelfImplementedIdentity)0; + _ = (IntId)0 <= (FullySelfImplementedIdentity)0; + _ = (IntId)0 > (FullySelfImplementedIdentity)0; + _ = (IntId)0 >= (FullySelfImplementedIdentity)0; + + _ = (IntId?)0 == (FullySelfImplementedIdentity)0; + _ = (IntId?)0 != (FullySelfImplementedIdentity)0; + _ = (IntId?)0 < (FullySelfImplementedIdentity)0; + _ = (IntId?)0 <= (FullySelfImplementedIdentity)0; + _ = (IntId?)0 > (FullySelfImplementedIdentity)0; + _ = (IntId?)0 >= (FullySelfImplementedIdentity)0; + + _ = (IntId)0 == (FullySelfImplementedIdentity?)0; + _ = (IntId)0 != (FullySelfImplementedIdentity?)0; + _ = (IntId)0 < (FullySelfImplementedIdentity?)0; + _ = (IntId)0 <= (FullySelfImplementedIdentity?)0; + _ = (IntId)0 > (FullySelfImplementedIdentity?)0; + _ = (IntId)0 >= (FullySelfImplementedIdentity?)0; + + _ = (IntId?)0 == (FullySelfImplementedIdentity?)0; + _ = (IntId?)0 != (FullySelfImplementedIdentity?)0; + _ = (IntId?)0 < (FullySelfImplementedIdentity?)0; + _ = (IntId?)0 <= (FullySelfImplementedIdentity?)0; + _ = (IntId?)0 > (FullySelfImplementedIdentity?)0; + _ = (IntId?)0 >= (FullySelfImplementedIdentity?)0; + } + + public static void CompareUnrelatedWrapperValueObjects_Always_ShouldWarn() + { + _ = (IntValue)0 == (FullySelfImplementedWrapperValueObject)0; + _ = (IntValue)0 != (FullySelfImplementedWrapperValueObject)0; + _ = (IntValue)0 < (FullySelfImplementedWrapperValueObject)0; + _ = (IntValue)0 <= (FullySelfImplementedWrapperValueObject)0; + _ = (IntValue)0 > (FullySelfImplementedWrapperValueObject)0; + _ = (IntValue)0 >= (FullySelfImplementedWrapperValueObject)0; + +#pragma warning disable CS8604 // Possible null reference argument. -- True, but we just want to use this to test an analyzer" + _ = (IntValue?)0 == (FullySelfImplementedWrapperValueObject)0; + _ = (IntValue?)0 != (FullySelfImplementedWrapperValueObject)0; + _ = (IntValue?)0 < (FullySelfImplementedWrapperValueObject)0; + _ = (IntValue?)0 <= (FullySelfImplementedWrapperValueObject)0; + _ = (IntValue?)0 > (FullySelfImplementedWrapperValueObject)0; + _ = (IntValue?)0 >= (FullySelfImplementedWrapperValueObject)0; + + _ = (IntValue)0 == (FullySelfImplementedWrapperValueObject?)0; + _ = (IntValue)0 != (FullySelfImplementedWrapperValueObject?)0; + _ = (IntValue)0 < (FullySelfImplementedWrapperValueObject?)0; + _ = (IntValue)0 <= (FullySelfImplementedWrapperValueObject?)0; + _ = (IntValue)0 > (FullySelfImplementedWrapperValueObject?)0; + _ = (IntValue)0 >= (FullySelfImplementedWrapperValueObject?)0; +#pragma warning restore CS8604 // Possible null reference argument. + } + + public static void CompareUnrelatedWrapperValueObjectToCoreType_Always_ShouldWarn() + { + _ = (IntValue)0 == 0; + _ = (IntValue)0 != 0; + _ = (IntValue)0 < 0; + _ = (IntValue)0 <= 0; + _ = (IntValue)0 > 0; + _ = (IntValue)0 >= 0; + +#pragma warning disable CS8604 // Possible null reference argument. -- True, but we just want to use this to test an analyzer" + _ = (IntValue?)0 == 0; + _ = (IntValue?)0 != 0; + _ = (IntValue?)0 < 0; + _ = (IntValue?)0 <= 0; + _ = (IntValue?)0 > 0; + _ = (IntValue?)0 >= 0; +#pragma warning restore CS8604 // Possible null reference argument. + + _ = (IntValue)0 == (int?)0; + _ = (IntValue)0 != (int?)0; + _ = (IntValue)0 < (int?)0; + _ = (IntValue)0 <= (int?)0; + _ = (IntValue)0 > (int?)0; + _ = (IntValue)0 >= (int?)0; + + _ = (IntValue?)0 == (int?)0; + _ = (IntValue?)0 != (int?)0; + _ = (IntValue?)0 < (int?)0; + _ = (IntValue?)0 <= (int?)0; + _ = (IntValue?)0 > (int?)0; + _ = (IntValue?)0 >= (int?)0; + } +} diff --git a/DomainModeling.Tests/DomainModeling.Tests.csproj b/DomainModeling.Tests/DomainModeling.Tests.csproj index cc4fdd9..f27ed2f 100644 --- a/DomainModeling.Tests/DomainModeling.Tests.csproj +++ b/DomainModeling.Tests/DomainModeling.Tests.csproj @@ -35,6 +35,7 @@ + diff --git a/DomainModeling.sln b/DomainModeling.sln index 1655b39..19bbe15 100644 --- a/DomainModeling.sln +++ b/DomainModeling.sln @@ -14,13 +14,15 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{08DABA83-2014-4A2F-A584-B5FFA6FEA45D}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + LICENSE = LICENSE pipeline-publish-preview.yml = pipeline-publish-preview.yml pipeline-publish-stable.yml = pipeline-publish-stable.yml pipeline-verify.yml = pipeline-verify.yml README.md = README.md - LICENSE = LICENSE EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DomainModeling.Analyzer", "DomainModeling.Analyzer\DomainModeling.Analyzer.csproj", "{39B467AB-9E95-47AF-713B-D37A02BD964B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -43,6 +45,10 @@ Global {F4035B17-3F4B-4298-A68E-AD3B730A4DB6}.Debug|Any CPU.Build.0 = Debug|Any CPU {F4035B17-3F4B-4298-A68E-AD3B730A4DB6}.Release|Any CPU.ActiveCfg = Release|Any CPU {F4035B17-3F4B-4298-A68E-AD3B730A4DB6}.Release|Any CPU.Build.0 = Release|Any CPU + {39B467AB-9E95-47AF-713B-D37A02BD964B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39B467AB-9E95-47AF-713B-D37A02BD964B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39B467AB-9E95-47AF-713B-D37A02BD964B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39B467AB-9E95-47AF-713B-D37A02BD964B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DomainModeling/Architect.DomainModeling.targets b/DomainModeling/Architect.DomainModeling.targets new file mode 100644 index 0000000..26b16f6 --- /dev/null +++ b/DomainModeling/Architect.DomainModeling.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index d68d24a..8b9aaa9 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -89,8 +89,20 @@ Misc: + + + + + + + + + + + + From f2040dddcfbebd18ba519f709516d6791966b802 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Mon, 15 Sep 2025 15:00:20 +0200 Subject: [PATCH 15/23] Moved Equals() and Compare() helpers from generated into helper class. --- DomainModeling.Generator/IdentityGenerator.cs | 2 +- .../TypeSymbolExtensions.cs | 4 ++-- .../ValueObjectGenerator.cs | 21 +------------------ .../WrapperValueObjectGenerator.cs | 2 +- .../InferredTypeDefaultComparer.cs | 21 +++++++++++++++++++ DomainModeling/DomainModeling.csproj | 1 + 6 files changed, 27 insertions(+), 24 deletions(-) create mode 100644 DomainModeling/Comparisons/InferredTypeDefaultComparer.cs diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index 3c30d02..0b1872c 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -581,7 +581,7 @@ namespace {containingNamespace} static {idTypeName} IValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Deserialize({underlyingTypeFullyQualifiedName} value) {{ {(existingComponents.HasFlags(IdTypeComponents.UnsettableValue) ? "// To instead get safe syntax, make the Value property '{ get; private init; }' (or let the source generator implement it)" : "")} - {(existingComponents.HasFlags(IdTypeComponents.UnsettableValue) ? $"return System.Runtime.CompilerServices.Unsafe.As<{underlyingTypeFullyQualifiedName}, {idTypeName}>(ref value);" : "")} + {(existingComponents.HasFlags(IdTypeComponents.UnsettableValue) ? $"return Unsafe.As<{underlyingTypeFullyQualifiedName}, {idTypeName}>(ref value);" : "")} {(existingComponents.HasFlags(IdTypeComponents.UnsettableValue) ? "//" : "")}return new {idTypeName}() {{ Value = value }}; }} {(existingComponents.HasFlags(IdTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} diff --git a/DomainModeling.Generator/TypeSymbolExtensions.cs b/DomainModeling.Generator/TypeSymbolExtensions.cs index d7ede56..59da690 100644 --- a/DomainModeling.Generator/TypeSymbolExtensions.cs +++ b/DomainModeling.Generator/TypeSymbolExtensions.cs @@ -684,7 +684,7 @@ public static string CreateEqualityExpression(this ITypeSymbol typeSymbol, strin // DO NOT REORDER // Not yet source-generated - if (typeSymbol.TypeKind == TypeKind.Error) return $"Equals(this.{memberName}, other.{memberName})"; + if (typeSymbol.TypeKind == TypeKind.Error) return $"{ComparisonsNamespace}.InferredTypeDefaultComparer.Equals(this.{memberName}, other.{memberName})"; if (typeSymbol.SpecialType == SpecialType.System_String) return String.Format(stringVariant, memberName); @@ -733,7 +733,7 @@ public static string CreateComparisonExpression(this ITypeSymbol typeSymbol, str // DO NOT REORDER // Not yet source-generated - if (typeSymbol.TypeKind == TypeKind.Error) return $"Compare(this.{memberName}, other.{memberName})"; + if (typeSymbol.TypeKind == TypeKind.Error) return $"{ComparisonsNamespace}.InferredTypeDefaultComparer.Compare(this.{memberName}, other.{memberName})"; // Collections have not been implemented, as we do not generate CompareTo() if any data member is not IComparable (as is the case for collections) diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index b374cb0..4d0ae85 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -322,33 +322,14 @@ public bool Equals({typeName}? other) }} {(existingComponents.HasFlags(ValueObjectTypeComponents.EqualsMethod) ? " */" : "")} - /// - /// Provides type inference when comparing types that are entirely source-generated. The current code's source generator does not know the appropriate namespace, because the type is being generated at the same time, thus necessitating type inference. - /// - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] - private static bool Equals(T left, T right) - {{ - return EqualityComparer.Default.Equals(left, right); - }} - {(existingComponents.HasFlags(ValueObjectTypeComponents.CompareToMethod) ? "/*" : "")} - {(isComparable ? "" : "/*")} - // This method is generated only if the ValueObject implements IComparable against its own type and each data member implements IComparable against its own type + {(isComparable ? "" : "/* Generated only if the ValueObject implements IComparable against its own type and each data member implements IComparable against its own type")} public int CompareTo({typeName}? other) {{ if (other is null) return +1; {compareToBodyIfInstanceNonNull} }} - - /// - /// Provides type inference when comparing types that are entirely source-generated. The current code's source generator does not know the appropriate namespace, because the type is being generated at the same time, thus necessitating type inference. - /// - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] - private static int Compare(T left, T right) - {{ - return Comparer.Default.Compare(left, right); - }} {(isComparable ? "" : "*/")} {(existingComponents.HasFlags(ValueObjectTypeComponents.CompareToMethod) ? "*/" : "")} diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index 1f4ea50..fc5f60a 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -498,7 +498,7 @@ namespace {containingNamespace} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DeserializeFromUnderlying) ? "/*" : "")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.UnsettableValue) ? $@" - [System.Runtime.CompilerServices.UnsafeAccessor(System.Runtime.CompilerServices.UnsafeAccessorKind.Field, Name = ""{valueFieldName}"")] + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = ""{valueFieldName}"")] private static extern ref {underlyingTypeFullyQualifiedName} GetValueFieldReference({typeName} instance);" : "")} /// diff --git a/DomainModeling/Comparisons/InferredTypeDefaultComparer.cs b/DomainModeling/Comparisons/InferredTypeDefaultComparer.cs new file mode 100644 index 0000000..477e267 --- /dev/null +++ b/DomainModeling/Comparisons/InferredTypeDefaultComparer.cs @@ -0,0 +1,21 @@ +using System.Runtime.CompilerServices; + +namespace Architect.DomainModeling.Comparisons; + +/// +/// Performs default comparisons with type inference, where the standard syntax does not allow for type inference. +/// +public static class InferredTypeDefaultComparer +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool Equals(T left, T right) + { + return EqualityComparer.Default.Equals(left, right); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int Compare(T left, T right) + { + return Comparer.Default.Compare(left, right); + } +} diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 8b9aaa9..bdb1f71 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -52,6 +52,7 @@ Correct string comparisons with EF: Performance: - Enhancement: Reduced assembly size by having source-generated WrapperValueObject/Identity types use generic JSON serializers instead of generating their own. +- Enhancement: Reduced assembly size by moving the type-inference Equals() and Compare() helpers on generated ValueObjects into a helper class. - Enhancement: Improved source generator performance. Misc: From 2b5e7ba4d17e61893d35a4b6c37c503545d641a7 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Mon, 15 Sep 2025 15:09:10 +0200 Subject: [PATCH 16/23] Generated [Wrapper]ValueObjects can now be [structs and/or] records and/or use a custom base class. --- ...ueObjectMissingStringComparisonAnalyzer.cs | 73 +++ ...perValueObjectDefaultExpressionAnalyzer.cs | 62 +++ ...ueObjectMissingStringComparisonAnalyzer.cs | 73 +++ .../DomainModeling.Analyzer.csproj | 1 - .../DomainModeling.CodeFixProviders.csproj | 30 ++ .../MissingStringComparisonCodeFixProvider.cs | 102 ++++ DomainModeling.Example/Description.cs | 8 +- .../DomainModeling.Example.csproj | 1 + DomainModeling.Example/Program.cs | 6 +- .../EntityFrameworkConfigurationGenerator.cs | 4 +- .../DiagnosticReportingExtensions.cs | 23 +- .../DomainEventGenerator.cs | 2 +- .../DomainModeling.Generator.csproj | 1 - DomainModeling.Generator/EntityGenerator.cs | 2 +- DomainModeling.Generator/IdentityGenerator.cs | 237 ++++---- .../TypeSymbolExtensions.cs | 26 +- .../ValueObjectGenerator.cs | 35 +- .../ValueWrapperGenerator.cs | 105 +++- .../WrapperValueObjectGenerator.cs | 364 +++++++------ ...jectMissingStringComparisonAnalyzerTest.cs | 14 + ...lueObjectDefaultExpressionAnalyzerTests.cs | 23 + ...jectMissingStringComparisonAnalyzerTest.cs | 13 + .../Comparisons/EnumerableComparerTests.cs | 2 +- .../DomainModeling.Tests.csproj | 1 + DomainModeling.Tests/DummyBuilderTests.cs | 4 +- ...ityFrameworkConfigurationGeneratorTests.cs | 4 +- DomainModeling.Tests/IdentityTests.cs | 214 ++++++-- .../ValueObjectExtensionsTests.cs | 26 + DomainModeling.Tests/ValueObjectTests.cs | 122 ++--- .../WrapperValueObjectTests.cs | 283 ++++++++-- DomainModeling.sln | 6 + .../Architect.DomainModeling.targets | 1 + .../Comparisons/ValueObjectStringValidator.cs | 504 ++++++++++++++++++ .../Conversions/ValueWrapperUnwrapper.cs | 38 +- DomainModeling/DomainModeling.csproj | 45 +- .../ValueObject.ValidationHelpers.cs | 399 +------------- DomainModeling/ValueObjectExtensions.cs | 19 + 37 files changed, 1991 insertions(+), 882 deletions(-) create mode 100644 DomainModeling.Analyzer/Analyzers/ValueObjectMissingStringComparisonAnalyzer.cs create mode 100644 DomainModeling.Analyzer/Analyzers/WrapperValueObjectDefaultExpressionAnalyzer.cs create mode 100644 DomainModeling.Analyzer/Analyzers/WrapperValueObjectMissingStringComparisonAnalyzer.cs create mode 100644 DomainModeling.CodeFixProviders/DomainModeling.CodeFixProviders.csproj create mode 100644 DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs create mode 100644 DomainModeling.Tests/Analyzers/ValueObjectMissingStringComparisonAnalyzerTest.cs create mode 100644 DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs create mode 100644 DomainModeling.Tests/Analyzers/WrapperValueObjectMissingStringComparisonAnalyzerTest.cs create mode 100644 DomainModeling.Tests/ValueObjectExtensionsTests.cs create mode 100644 DomainModeling/Comparisons/ValueObjectStringValidator.cs create mode 100644 DomainModeling/ValueObjectExtensions.cs diff --git a/DomainModeling.Analyzer/Analyzers/ValueObjectMissingStringComparisonAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/ValueObjectMissingStringComparisonAnalyzer.cs new file mode 100644 index 0000000..1f1c99c --- /dev/null +++ b/DomainModeling.Analyzer/Analyzers/ValueObjectMissingStringComparisonAnalyzer.cs @@ -0,0 +1,73 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Architect.DomainModeling.Analyzer.Analyzers; + +// This is a separate analyzer because diagnostics directly from source generators appear less reliably, and this is an important diagnostic + +/// +/// Enforces a StringComparison property on annotated, partial ValueObject types with string members. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ValueObjectMissingStringComparisonAnalyzer : DiagnosticAnalyzer +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] + private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( + id: "ValueObjectGeneratorMissingStringComparison", + title: "ValueObject has string members but no StringComparison property", + messageFormat: "ValueObject {0} has string members but no StringComparison property to know how to compare them. Either wrap string members in dedicated WrapperValueObjects, or implement 'private StringComparison StringComparison => ...", + category: "Design", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterSyntaxNodeAction(AnalyzeClassDeclaration, + SyntaxKind.ClassDeclaration, + SyntaxKind.RecordDeclaration); + } + + private static void AnalyzeClassDeclaration(SyntaxNodeAnalysisContext context) + { + var tds = (TypeDeclarationSyntax)context.Node; + + // Only partial + if (!tds.Modifiers.Any(SyntaxKind.PartialKeyword)) + return; + + var semanticModel = context.SemanticModel; + var type = semanticModel.GetDeclaredSymbol(tds, context.CancellationToken); + + if (type is null) + return; + + // Only with ValueObjectAttribute + if (!type.GetAttributes().Any(attr => attr.AttributeClass is { Name: "ValueObjectAttribute", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } }, })) + return; + + // Only with string fields + if (!type.GetMembers().Any(member => member is IFieldSymbol { Type.SpecialType: SpecialType.System_String })) + return; + + // Only without StringComparison property (hand-written) + if (type.GetMembers("StringComparison").Any(member => member is IPropertySymbol { IsImplicitlyDeclared: false } prop && + prop.DeclaringSyntaxReferences.Length > 0 && prop.DeclaringSyntaxReferences[0].SyntaxTree.FilePath?.EndsWith(".g.cs") == false)) + return; + + var diagnostic = Diagnostic.Create( + DiagnosticDescriptor, + tds.Identifier.GetLocation(), + type.Name); + + context.ReportDiagnostic(diagnostic); + } +} diff --git a/DomainModeling.Analyzer/Analyzers/WrapperValueObjectDefaultExpressionAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/WrapperValueObjectDefaultExpressionAnalyzer.cs new file mode 100644 index 0000000..3bac978 --- /dev/null +++ b/DomainModeling.Analyzer/Analyzers/WrapperValueObjectDefaultExpressionAnalyzer.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Architect.DomainModeling.Analyzer.Analyzers; + +/// +/// Prevents the use of default expressions and literals on struct WrapperValueObject types, so that validation cannot be circumvented. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class WrapperValueObjectDefaultExpressionAnalyzer : DiagnosticAnalyzer +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] + private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( + id: "WrapperValueObjectDefaultExpression", + title: "Default expression instantiating unvalidated value object", + messageFormat: "A 'default' expression would create an unvalidated instance of value object {0}. Use a parameterized constructor, or use IsDefault() to merely compare.", + category: "Design", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics); + + context.RegisterSyntaxNodeAction(AnalyzeDefaultExpressionOrLiteral, + SyntaxKind.DefaultExpression, + SyntaxKind.DefaultLiteralExpression); + } + + private static void AnalyzeDefaultExpressionOrLiteral(SyntaxNodeAnalysisContext context) + { + var defaultExpressionOrLiteral = (ExpressionSyntax)context.Node; + + var typeInfo = context.SemanticModel.GetTypeInfo(defaultExpressionOrLiteral, context.CancellationToken); + + if (typeInfo.Type is not { } type) + return; + + // Only for structs + if (!type.IsValueType) + return; + + // Only with WrapperValueObjectAttribute + if (!type.GetAttributes().Any(attr => + attr.AttributeClass is { Arity: 1, Name: "WrapperValueObjectAttribute", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } }, })) + return; + + var diagnostic = Diagnostic.Create( + DiagnosticDescriptor, + context.Node.GetLocation(), + type.Name); + + context.ReportDiagnostic(diagnostic); + } +} diff --git a/DomainModeling.Analyzer/Analyzers/WrapperValueObjectMissingStringComparisonAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/WrapperValueObjectMissingStringComparisonAnalyzer.cs new file mode 100644 index 0000000..c08ee15 --- /dev/null +++ b/DomainModeling.Analyzer/Analyzers/WrapperValueObjectMissingStringComparisonAnalyzer.cs @@ -0,0 +1,73 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Architect.DomainModeling.Analyzer.Analyzers; + +// This is a separate analyzer because diagnostics directly from source generators appear less reliably, and this is an important diagnostic + +/// +/// Enforces a StringComparison property on annotated, partial WrapperValueObject types with string members. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class WrapperValueObjectMissingStringComparisonAnalyzer : DiagnosticAnalyzer +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] + private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( + id: "WrapperValueObjectGeneratorMissingStringComparison", + title: "WrapperValueObject has string members but no StringComparison property", + messageFormat: "WrapperValueObject {0} has string members but no StringComparison property to know how to compare them. Implement 'private StringComparison StringComparison => ...", + category: "Design", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterSyntaxNodeAction(AnalyzeTypeDeclaration, + SyntaxKind.ClassDeclaration, + SyntaxKind.StructDeclaration, + SyntaxKind.RecordDeclaration, + SyntaxKind.RecordStructDeclaration); + } + + private static void AnalyzeTypeDeclaration(SyntaxNodeAnalysisContext context) + { + var tds = (TypeDeclarationSyntax)context.Node; + + // Only partial + if (!tds.Modifiers.Any(SyntaxKind.PartialKeyword)) + return; + + var semanticModel = context.SemanticModel; + var type = semanticModel.GetDeclaredSymbol(tds, context.CancellationToken); + + if (type is null) + return; + + // Only with WrapperValueObjectAttribute + if (!type.GetAttributes().Any(attr => + attr.AttributeClass is { Arity: 1, Name: "WrapperValueObjectAttribute", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } }, } attributeClass && + attributeClass.TypeArguments[0].SpecialType == SpecialType.System_String)) + return; + + // Only without StringComparison property (hand-written) + if (type.GetMembers("StringComparison").Any(member => member is IPropertySymbol { IsImplicitlyDeclared: false } prop && + prop.DeclaringSyntaxReferences.Length > 0 && prop.DeclaringSyntaxReferences[0].SyntaxTree.FilePath?.EndsWith(".g.cs") == false)) + return; + + var diagnostic = Diagnostic.Create( + DiagnosticDescriptor, + tds.Identifier.GetLocation(), + type.Name); + + context.ReportDiagnostic(diagnostic); + } +} diff --git a/DomainModeling.Analyzer/DomainModeling.Analyzer.csproj b/DomainModeling.Analyzer/DomainModeling.Analyzer.csproj index 6afff14..ab64a24 100644 --- a/DomainModeling.Analyzer/DomainModeling.Analyzer.csproj +++ b/DomainModeling.Analyzer/DomainModeling.Analyzer.csproj @@ -22,7 +22,6 @@ - diff --git a/DomainModeling.CodeFixProviders/DomainModeling.CodeFixProviders.csproj b/DomainModeling.CodeFixProviders/DomainModeling.CodeFixProviders.csproj new file mode 100644 index 0000000..67839d8 --- /dev/null +++ b/DomainModeling.CodeFixProviders/DomainModeling.CodeFixProviders.csproj @@ -0,0 +1,30 @@ + + + + netstandard2.0 + Architect.DomainModeling.CodeFixProviders + Architect.DomainModeling.CodeFixProviders + Enable + Enable + 13 + False + True + True + + + + + IDE0057 + + + + + + + + + + + + + diff --git a/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs b/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs new file mode 100644 index 0000000..d033e40 --- /dev/null +++ b/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs @@ -0,0 +1,102 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Architect.DomainModeling.CodeFixProviders; + +[Shared] +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MissingStringComparisonCodeFixProvider))] +public sealed class MissingStringComparisonCodeFixProvider : CodeFixProvider +{ + private static readonly ImmutableArray FixableDiagnosticIdConstant = ["ValueObjectGeneratorMissingStringComparison", "WrapperValueObjectGeneratorMissingStringComparison"]; + + public override ImmutableArray FixableDiagnosticIds => FixableDiagnosticIdConstant; + + public override FixAllProvider? GetFixAllProvider() + { + return null; + } + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var diagnostic = context.Diagnostics.First(diagnostic => diagnostic.Id == FixableDiagnosticIdConstant[0] || diagnostic.Id == FixableDiagnosticIdConstant[1]); + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + return; + + var token = root.FindToken(diagnostic.Location.SourceSpan.Start); + var tds = token.Parent?.AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + if (tds is null) + return; + + var ordinalFix = CodeAction.Create( + title: "Implement StringComparison { get; } with StringComparison.Ordinal", + createChangedDocument: ct => AddStringComparisonMemberAsync(context.Document, root, tds, stringComparisonExpression: "StringComparison.Ordinal", ct), + equivalenceKey: "ImplementStringComparisonOrdinalGetter"); + context.RegisterCodeFix(ordinalFix, context.Diagnostics.First()); + + var ordinalIgnoreCaseFix = CodeAction.Create( + title: "Implement StringComparison { get; } with StringComparison.OrdinalIgnoreCase", + createChangedDocument: ct => AddStringComparisonMemberAsync(context.Document, root, tds, stringComparisonExpression: "StringComparison.OrdinalIgnoreCase", ct), + equivalenceKey: "ImplementStringComparisonOrdinalIgnoreCaseGetter"); + context.RegisterCodeFix(ordinalIgnoreCaseFix, context.Diagnostics.First()); + } + + private static Task AddStringComparisonMemberAsync( + Document document, + SyntaxNode root, + TypeDeclarationSyntax tds, + string stringComparisonExpression, + CancellationToken _) + { + var newlineTrivia = GetNewlineTrivia(tds); + + var property = SyntaxFactory.PropertyDeclaration( + SyntaxFactory.ParseTypeName("StringComparison"), + SyntaxFactory.Identifier("StringComparison")) + .AddModifiers(SyntaxFactory.Token(SyntaxKind.PrivateKeyword)) + .WithExpressionBody( + SyntaxFactory.ArrowExpressionClause( + SyntaxFactory.ParseExpression(stringComparisonExpression))) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)) + .WithLeadingTrivia(newlineTrivia) + .WithTrailingTrivia(newlineTrivia) + .WithTrailingTrivia(newlineTrivia); + + var updatedTds = tds.WithMembers(tds.Members.Insert(0, property)); + var updatedRoot = root.ReplaceNode(tds, updatedTds); + return Task.FromResult(document.WithSyntaxRoot(updatedRoot)); + } + + private static SyntaxTrivia GetNewlineTrivia(SyntaxNode node) + { + var allTrivia = node.DescendantTrivia(descendIntoTrivia: true); + + var (nCount, rnCount) = (0, 0); + + foreach (var trivia in allTrivia) + { + if (!trivia.IsKind(SyntaxKind.EndOfLineTrivia)) + continue; + + var length = trivia.Span.Length; + var lengthIsOne = length == 1; + var lengthIsTwo = length == 2; + nCount += Unsafe.As(ref lengthIsOne); + rnCount += Unsafe.As(ref lengthIsTwo); + } + + return rnCount > nCount + ? SyntaxFactory.ElasticCarriageReturnLineFeed + : SyntaxFactory.ElasticLineFeed; + } +} diff --git a/DomainModeling.Example/Description.cs b/DomainModeling.Example/Description.cs index f4b6cb5..02cdf2a 100644 --- a/DomainModeling.Example/Description.cs +++ b/DomainModeling.Example/Description.cs @@ -1,12 +1,14 @@ +using Architect.DomainModeling.Comparisons; + 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 [WrapperValueObject] -public partial class Description //: IComparable +public partial record struct Description //: IComparable { // For string wrappers, we must define how they are compared - protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; + private 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 @@ -19,6 +21,6 @@ public Description(string value) if (this.Value.Length > 255) throw new ArgumentException("Too long."); - if (ContainsNonWordCharacters(this.Value)) throw new ArgumentException("Nonsense."); + if (ValueObjectStringValidator.ContainsNonWordCharacters(this.Value)) throw new ArgumentException("Nonsense."); } } diff --git a/DomainModeling.Example/DomainModeling.Example.csproj b/DomainModeling.Example/DomainModeling.Example.csproj index 6747404..4c48d45 100644 --- a/DomainModeling.Example/DomainModeling.Example.csproj +++ b/DomainModeling.Example/DomainModeling.Example.csproj @@ -21,6 +21,7 @@ + diff --git a/DomainModeling.Example/Program.cs b/DomainModeling.Example/Program.cs index cc8cce0..b4e29e2 100644 --- a/DomainModeling.Example/Program.cs +++ b/DomainModeling.Example/Program.cs @@ -82,9 +82,9 @@ public static void Main() { Console.WriteLine("Demonstrating structural equality for collections:"); - var abc = new CharacterSet([ 'a', 'b', 'c', ]); - var abcd = new CharacterSet([ 'a', 'b', 'c', 'd', ]); - var abcClone = new CharacterSet([ 'a', 'b', 'c', ]); + var abc = new CharacterSet(['a', 'b', 'c',]); + var abcd = new CharacterSet(['a', 'b', 'c', 'd',]); + var abcClone = new CharacterSet(['a', 'b', 'c',]); Console.WriteLine($"{abc == abcd}: {abc} == {abcd} (different values)"); Console.WriteLine($"{abc == abcClone}: {abc} == {abcClone} (different instances, same values in collection)"); // ValueObjects have structural equality diff --git a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs index cfd77ee..33d411b 100644 --- a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs +++ b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs @@ -677,7 +677,7 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven 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 +#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 when not using it 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 = UninitializedInstantiationBinding.Create(() => DomainObjectSerializer.Deserialize()); #pragma warning restore EF1001 // Internal EF Core API usage @@ -690,7 +690,7 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven 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 +#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 when not using it 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 = UninitializedInstantiationBinding.Create(() => DomainObjectSerializer.Deserialize()); #pragma warning restore EF1001 // Internal EF Core API usage diff --git a/DomainModeling.Generator/DiagnosticReportingExtensions.cs b/DomainModeling.Generator/DiagnosticReportingExtensions.cs index 08dc26d..6691b39 100644 --- a/DomainModeling.Generator/DiagnosticReportingExtensions.cs +++ b/DomainModeling.Generator/DiagnosticReportingExtensions.cs @@ -9,30 +9,45 @@ namespace Architect.DomainModeling.Generator; internal static class DiagnosticReportingExtensions { /// + /// /// Shorthand extension method to report a diagnostic, with less boilerplate code. + /// + /// + /// This overload is only available when the compilation is available at generation time. + /// /// public static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, ISymbol? symbol = null) { - context.ReportDiagnostic(id, title, description, severity, symbol?.Locations.FirstOrDefault()); + context.ReportDiagnostic(id: id, title: title, description: description, severity: severity, location: symbol?.Locations.FirstOrDefault()); } /// + /// /// Shorthand extension method to report a diagnostic, with less boilerplate code. + /// + /// + /// This overload is only available when the compilation is available at generation time. + /// /// - public static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, Location? location) + private static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, Location? location) { context.ReportDiagnostic(Diagnostic.Create( - new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true), + new DiagnosticDescriptor(id: id, title: title, messageFormat: description, category: "Design", defaultSeverity: severity, isEnabledByDefault: true), location)); } /// + /// /// Shorthand extension method to report a diagnostic, with less boilerplate code. + /// + /// + /// This overload makes use of the properly cacheable , because should not be passed between source generation steps. + /// /// 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), + new DiagnosticDescriptor(id: id, title: title, messageFormat: description, category: "Design", defaultSeverity: severity, isEnabledByDefault: true), location)); } } diff --git a/DomainModeling.Generator/DomainEventGenerator.cs b/DomainModeling.Generator/DomainEventGenerator.cs index 07f9d20..c1b8cc2 100644 --- a/DomainModeling.Generator/DomainEventGenerator.cs +++ b/DomainModeling.Generator/DomainEventGenerator.cs @@ -89,7 +89,7 @@ private static void GenerateSource(SourceProductionContext context, Generatable // Require the expected inheritance if (!generatable.IsDomainObject) { - context.ReportDiagnostic("DomainEventGeneratorUnexpectedInheritance", "Unexpected inheritance", + context.ReportDiagnostic("DomainEventGeneratorMissingInterface", "Missing IDomainObject interface", "Type marked as domain event lacks IDomainObject interface.", DiagnosticSeverity.Warning, generatable.TypeLocation); return; } diff --git a/DomainModeling.Generator/DomainModeling.Generator.csproj b/DomainModeling.Generator/DomainModeling.Generator.csproj index 0c780c4..94a55dc 100644 --- a/DomainModeling.Generator/DomainModeling.Generator.csproj +++ b/DomainModeling.Generator/DomainModeling.Generator.csproj @@ -22,7 +22,6 @@ - diff --git a/DomainModeling.Generator/EntityGenerator.cs b/DomainModeling.Generator/EntityGenerator.cs index f5ff483..899bb68 100644 --- a/DomainModeling.Generator/EntityGenerator.cs +++ b/DomainModeling.Generator/EntityGenerator.cs @@ -89,7 +89,7 @@ private static void GenerateSource(SourceProductionContext context, Generatable // Require the expected inheritance if (!generatable.IsEntity) { - context.ReportDiagnostic("EntityGeneratorUnexpectedInheritance", "Unexpected inheritance", + context.ReportDiagnostic("EntityGeneratorMissingInterface", "Missing IEntity interface", "Type marked as entity lacks IEntity interface.", DiagnosticSeverity.Warning, generatable.TypeLocation); return; } diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index 0b1872c..5880eb7 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -29,35 +29,19 @@ INamedTypeSymbol type when LooksLikeEntity(type) && IsEntity(type, out var entit entityInterface.TypeArguments[1] is ITypeSymbol underlyingType => new ValueWrapperGenerator.BasicGeneratable( isIdentity: true, - typeName: entityInterface.TypeArguments[0].Name, containingNamespace: type.ContainingNamespace.ToString(), - underlyingTypeFullyQualifiedName: underlyingType.ToString(), - customCoreTypeFullyQualifiedName: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[1].ToString(), - isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), - isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }), - isUtf8SpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), - isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), + wrapperType: entityInterface.TypeArguments[0], + underlyingType: underlyingType, + customCoreType: null), INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attribute.AttributeClass!.TypeArguments[0] is ITypeSymbol underlyingType => GetFirstProblem((TypeDeclarationSyntax)context.Node, type, underlyingType) is { } ? default : new ValueWrapperGenerator.BasicGeneratable( isIdentity: true, - typeName: type.Name, containingNamespace: type.ContainingNamespace.ToString(), - underlyingTypeFullyQualifiedName: underlyingType.ToString(), - customCoreTypeFullyQualifiedName: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[1].ToString(), - isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), - isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }), - isUtf8SpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), - isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), + wrapperType: type, + underlyingType: underlyingType, + customCoreType: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[1]), _ => default, }) .Where(generatable => generatable != default) @@ -110,7 +94,7 @@ private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeDat // Require the expected inheritance if (!isPartial && !type.IsOrImplementsInterface(interf => interf.IsType("IIdentity", "Architect", "DomainModeling", arity: 1), out _)) - return CreateDiagnostic("IdentityGeneratorUnexpectedInheritance", "Unexpected interface", + return CreateDiagnostic("IdentityGeneratorMissingInterface", "Missing IIdentity interface", "Type marked as identity value object lacks IIdentity interface. Did you forget the 'partial' keyword and elude source generation?", DiagnosticSeverity.Warning); // Require IDirectValueWrapper @@ -304,23 +288,23 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= IdTypeComponents.GreaterThanOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= IdTypeComponents.LessThanOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= IdTypeComponents.GreaterEqualsOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= IdTypeComponents.LessEqualsOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= IdTypeComponents.ConvertToOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && @@ -335,16 +319,12 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= IdTypeComponents.NullableConvertToOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && method.ReturnType.IsNullableOf(type) && - (underlyingType.IsReferenceType - ? method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default) - : method.Parameters[0].Type.IsNullableOf(underlyingType)))); + method.Parameters[0].Type.IsNullableOrReferenceOf(underlyingType))); existingComponents |= IdTypeComponents.NullableConvertFromOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && - (underlyingType.IsReferenceType - ? method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default) - : method.ReturnType.IsNullableOf(underlyingType) && - method.Parameters[0].Type.IsNullableOf(type)))); + method.ReturnType.IsNullableOrReferenceOf(underlyingType) && + method.Parameters[0].Type.IsNullableOf(type))); existingComponents |= IdTypeComponents.SerializeToUnderlying.If(members.Any(member => member.HasNameOrExplicitInterfaceImplementationName("Serialize") && member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 0, } method && @@ -362,7 +342,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella attribute.AttributeClass?.IsType("JsonConverterAttribute", "Newtonsoft", "Json") == true)); existingComponents |= IdTypeComponents.StringComparison.If(members.Any(member => - member.Name == "StringComparison")); + member is IPropertySymbol { Name: "StringComparison", IsImplicitlyDeclared: false, } prop)); existingComponents |= IdTypeComponents.FormattableToStringOverride.If(members.Any(member => member.HasNameOrExplicitInterfaceImplementationName("ToString") && member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 2, } method && @@ -451,6 +431,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella result.UnderlyingTypeIsNumericUnsuitableForJson = underlyingType.SpecialType is SpecialType.System_Decimal or SpecialType.System_UInt64 or SpecialType.System_Int64 || underlyingType.IsSystemType("BigInteger", "Numerics") || underlyingType.IsSystemType("UInt128") || underlyingType.IsSystemType("Int128"); result.UnderlyingTypeIsStruct = underlyingType.IsValueType; + result.UnderlyingTypeIsInterface = underlyingType.TypeKind == TypeKind.Interface; return result; } @@ -470,7 +451,6 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var containingNamespace = generatable.ContainingNamespace; var idTypeName = generatable.IdTypeName; - var underlyingTypeFullyQualifiedName = generatable.UnderlyingTypeFullyQualifiedName; var entityTypeName = generatable.EntityTypeName; var underlyingTypeIsStruct = generatable.UnderlyingTypeIsStruct; var isRecord = generatable.IsRecord; @@ -486,11 +466,24 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var existingComponents = generatable.ExistingComponents; var hasIdentityValueObjectAttribute = generatable.IdTypeExists; - var coreTypeFullyQualifiedName = ValueWrapperGenerator.GetCoreTypeFullyQualifiedName(valueWrappers, idTypeName, containingNamespace); + var directParentOfCore = ValueWrapperGenerator.GetDirectParentOfCoreType(valueWrappers, idTypeName, containingNamespace); + var coreTypeFullyQualifiedName = directParentOfCore.CustomCoreTypeFullyQualifiedName ?? directParentOfCore.UnderlyingTypeFullyQualifiedName ?? generatable.UnderlyingTypeFullyQualifiedName; + var coreTypeIsStruct = directParentOfCore.CoreTypeIsStruct; - (var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( + (var coreValueIsNonNull, var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( valueWrappers, typeName: idTypeName, containingNamespace: containingNamespace); + var underlyingTypeFullyQualifiedNameForAlias = generatable.UnderlyingTypeFullyQualifiedName; + var coreTypeFullyQualifiedNameForAlias = coreTypeFullyQualifiedName; + var underlyingTypeFullyQualifiedName = Char.IsUpper(underlyingTypeFullyQualifiedNameForAlias[0]) && !underlyingTypeFullyQualifiedNameForAlias.Contains('<') + ? underlyingTypeFullyQualifiedNameForAlias.Split('.').Last() + : underlyingTypeFullyQualifiedNameForAlias; + coreTypeFullyQualifiedName = coreTypeFullyQualifiedNameForAlias == underlyingTypeFullyQualifiedNameForAlias + ? underlyingTypeFullyQualifiedName + : Char.IsUpper(coreTypeFullyQualifiedName[0]) && !coreTypeFullyQualifiedName.Contains('<') + ? coreTypeFullyQualifiedName.Split('.').Last() + : coreTypeFullyQualifiedName; + var summary = entityTypeName is null ? null : $@" /// /// The identity type used for the entity. @@ -520,6 +513,8 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System.Runtime.CompilerServices; using Architect.DomainModeling; using Architect.DomainModeling.Conversions; +{(underlyingTypeFullyQualifiedName != underlyingTypeFullyQualifiedNameForAlias ? $"using {underlyingTypeFullyQualifiedName} = {underlyingTypeFullyQualifiedNameForAlias};" : "")} +{(coreTypeFullyQualifiedName != coreTypeFullyQualifiedNameForAlias && coreTypeFullyQualifiedName != underlyingTypeFullyQualifiedName ? $"using {coreTypeFullyQualifiedName} = {coreTypeFullyQualifiedNameForAlias};" : "")} #nullable enable @@ -554,6 +549,93 @@ namespace {containingNamespace} }} {(existingComponents.HasFlags(IdTypeComponents.Constructor) ? "*/" : "")} + {(existingComponents.HasFlags(IdTypeComponents.StringComparison) ? "/*" : "")} + {(isString + ? @"private StringComparison StringComparison => StringComparison.Ordinal;" + : "")} + {(existingComponents.HasFlags(IdTypeComponents.StringComparison) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.ToStringOverride) ? "/*" : "")}{nonNullStringSummary} + public override string{(isNonNullString || !isToStringNullable ? "" : "?")} ToString() + {{ + + return {(isINumber + ? """this.Value.ToString("0.#")""" + : toStringExpression)}; + }} + {(existingComponents.HasFlags(IdTypeComponents.ToStringOverride) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.GetHashCodeOverride) ? "/*" : "")} + public override int GetHashCode() + {{ +#pragma warning disable RS1024 // Compare symbols correctly + return {hashCodeExpression}; +#pragma warning restore RS1024 // Compare symbols correctly + }} + {(existingComponents.HasFlags(IdTypeComponents.GetHashCodeOverride) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.EqualsOverride) ? "/*" : "")} + public override bool Equals(object? other) + {{ + return other is {idTypeName} otherId && this.Equals(otherId); + }} + {(existingComponents.HasFlags(IdTypeComponents.EqualsOverride) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.EqualsMethod) ? "/*" : "")} + public bool Equals({idTypeName} other) + {{ + return {equalityExpression}; + }} + {(existingComponents.HasFlags(IdTypeComponents.EqualsMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.CompareToMethod) ? "/*" : "")} + public int CompareTo({idTypeName} other) + {{ + return {comparisonExpression}; + }} + {(existingComponents.HasFlags(IdTypeComponents.CompareToMethod) ? "*/" : "")} + + {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "//" : "")}public static bool operator ==({idTypeName} left, {idTypeName} right) => left.Equals(right); + {(existingComponents.HasFlags(IdTypeComponents.NotEqualsOperator) ? "//" : "")}public static bool operator !=({idTypeName} left, {idTypeName} right) => !(left == right); + + // Nullable comparison operators circumvent the unexpected behavior that would be caused by .NET's lifting + {(existingComponents.HasFlags(IdTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({idTypeName}? left, {idTypeName}? right) => left is {{ }} one && !(right is {{ }} two && one.CompareTo(two) <= 0); + {(existingComponents.HasFlags(IdTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({idTypeName}? left, {idTypeName}? right) => right is {{ }} two && !(left is {{ }} one && one.CompareTo(two) >= 0); + {(existingComponents.HasFlags(IdTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({idTypeName}? left, {idTypeName}? right) => !(left < right); + {(existingComponents.HasFlags(IdTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({idTypeName}? left, {idTypeName}? right) => !(left > right); + + {(existingComponents.HasFlags(IdTypeComponents.ConvertToOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } + ? "" + : $@"public static implicit operator {idTypeName}({underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct ? "" : "?")} value) => new {idTypeName}(value);")} + {(existingComponents.HasFlags(IdTypeComponents.ConvertFromOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } + ? "" + : $@"public static implicit operator {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")}({idTypeName} id) => id.Value;")} + + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } + ? "" + : @"[return: NotNullIfNotNull(nameof(value))]")} + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } + ? "" + : $@"public static implicit operator {idTypeName}?({underlyingTypeFullyQualifiedName}? value) => value is {{ }} actual ? new {idTypeName}(actual) : ({idTypeName}?)null;")} + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } + ? "" + : underlyingTypeIsStruct || isNonNullString ? @"[return: NotNullIfNotNull(nameof(id))]" : "[return: MaybeNull]")} + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } + ? "" + : $@"public static implicit operator {underlyingTypeFullyQualifiedName}?({idTypeName}? id) => id?.Value;")} + + {(coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "/* For nested wrapper types only" : "")} + {(existingComponents.HasFlags(IdTypeComponents.ConvertToOperator) ? "//" : "")}public static implicit operator {idTypeName}({coreTypeFullyQualifiedName} value) => ValueWrapperUnwrapper.Wrap<{idTypeName}, {coreTypeFullyQualifiedName}>(value); + {(existingComponents.HasFlags(IdTypeComponents.ConvertFromOperator) ? "//" : "")}public static implicit operator {coreTypeFullyQualifiedName}{(coreTypeIsStruct || coreValueIsNonNull ? "" : "?")}({idTypeName} id) => ValueWrapperUnwrapper.Unwrap<{idTypeName}, {coreTypeFullyQualifiedName}>(id){(coreValueIsNonNull ? "!" : "")}; + + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "//" : "")}[return: NotNullIfNotNull(nameof(value))] + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "//" : "")}public static implicit operator {idTypeName}?({coreTypeFullyQualifiedName}? value) => value is {{ }} actual ? ValueWrapperUnwrapper.Wrap<{idTypeName}, {coreTypeFullyQualifiedName}>(actual) : ({idTypeName}?)null; + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "//" : "")}[return: {(coreTypeIsStruct || coreValueIsNonNull ? @"NotNullIfNotNull(nameof(id))" : @"MaybeNull")}] + {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "//" : "")}public static implicit operator {coreTypeFullyQualifiedName}?({idTypeName}? id) => id is {{ }} actual ? ValueWrapperUnwrapper.Unwrap<{idTypeName}, {coreTypeFullyQualifiedName}>(actual) : ({coreTypeFullyQualifiedName}?)null; + {(coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "*/" : "")} + + #region Wrapping & Serialization + {(existingComponents.HasFlags(IdTypeComponents.CreateMethod) ? "/*" : "")} [MethodImpl(MethodImplOptions.AggressiveInlining)] static {idTypeName} IValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Create({underlyingTypeFullyQualifiedName} value) @@ -586,9 +668,9 @@ namespace {containingNamespace} }} {(existingComponents.HasFlags(IdTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} - {(generatable.ExistingComponents.HasFlags(IdTypeComponents.CoreValueWrapperInterface) ? "/* Core manually specified" : coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "/* For nested wrapper types only" : "")} + {(generatable.ExistingComponents.HasFlags(IdTypeComponents.CoreValueWrapperInterface) ? "/* Up to developer because core type was customized" : coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "/* For nested wrapper types only" : "")} [MaybeNull] - {coreTypeFullyQualifiedName} IValueWrapper<{idTypeName}, {coreTypeFullyQualifiedName}>.Value => ValueWrapperUnwrapper.Unwrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(this.Value); + {coreTypeFullyQualifiedName} IValueWrapper<{idTypeName}, {coreTypeFullyQualifiedName}>.Value => this.Value is {{ }} actual ? ValueWrapperUnwrapper.Unwrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(actual) : default; [MethodImpl(MethodImplOptions.AggressiveInlining)] static {idTypeName} IValueWrapper<{idTypeName}, {coreTypeFullyQualifiedName}>.Create({coreTypeFullyQualifiedName} value) @@ -604,8 +686,8 @@ namespace {containingNamespace} [return: MaybeNull] {coreTypeFullyQualifiedName} IValueWrapper<{idTypeName}, {coreTypeFullyQualifiedName}>.Serialize() {{ - return DomainObjectSerializer.Serialize<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>( - DomainObjectSerializer.Serialize<{idTypeName}, {underlyingTypeFullyQualifiedName}>(this)); + var intermediateValue = DomainObjectSerializer.Serialize<{idTypeName}, {underlyingTypeFullyQualifiedName}>(this); + return DomainObjectSerializer.Serialize<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(intermediateValue); }} /// @@ -619,67 +701,7 @@ namespace {containingNamespace} }} {(coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "*/" : generatable.ExistingComponents.HasFlags(IdTypeComponents.CoreValueWrapperInterface) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.StringComparison) ? "/*" : "")} - {(isString - ? @"private StringComparison StringComparison => StringComparison.Ordinal;" - : "")} - {(existingComponents.HasFlags(IdTypeComponents.StringComparison) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.ToStringOverride) ? "/*" : "")}{nonNullStringSummary} - public override string{(isNonNullString || !isToStringNullable ? "" : "?")} ToString() - {{ - - return {(isINumber - ? """this.Value.ToString("0.#")""" - : toStringExpression)}; - }} - {(existingComponents.HasFlags(IdTypeComponents.ToStringOverride) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.GetHashCodeOverride) ? "/*" : "")} - public override int GetHashCode() - {{ -#pragma warning disable RS1024 // Compare symbols correctly - return {hashCodeExpression}; -#pragma warning restore RS1024 // Compare symbols correctly - }} - {(existingComponents.HasFlags(IdTypeComponents.GetHashCodeOverride) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.EqualsOverride) ? "/*" : "")} - public override bool Equals(object? other) - {{ - return other is {idTypeName} otherId && this.Equals(otherId); - }} - {(existingComponents.HasFlags(IdTypeComponents.EqualsOverride) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.EqualsMethod) ? "/*" : "")} - public bool Equals({idTypeName} other) - {{ - return {equalityExpression}; - }} - {(existingComponents.HasFlags(IdTypeComponents.EqualsMethod) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.CompareToMethod) ? "/*" : "")} - public int CompareTo({idTypeName} other) - {{ - return {comparisonExpression}; - }} - {(existingComponents.HasFlags(IdTypeComponents.CompareToMethod) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "//" : "")}public static bool operator ==({idTypeName} left, {idTypeName} right) => left.Equals(right); - {(existingComponents.HasFlags(IdTypeComponents.NotEqualsOperator) ? "//" : "")}public static bool operator !=({idTypeName} left, {idTypeName} right) => !(left == right); - - {(existingComponents.HasFlags(IdTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({idTypeName} left, {idTypeName} right) => left.CompareTo(right) > 0; - {(existingComponents.HasFlags(IdTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({idTypeName} left, {idTypeName} right) => left.CompareTo(right) < 0; - {(existingComponents.HasFlags(IdTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({idTypeName} left, {idTypeName} right) => left.CompareTo(right) >= 0; - {(existingComponents.HasFlags(IdTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({idTypeName} left, {idTypeName} right) => left.CompareTo(right) <= 0; - - {(existingComponents.HasFlags(IdTypeComponents.ConvertToOperator) ? "//" : "")}public static implicit operator {idTypeName}({underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct ? "" : "?")} value) => new {idTypeName}(value); - {(existingComponents.HasFlags(IdTypeComponents.ConvertFromOperator) ? "//" : "")}public static implicit operator {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")}({idTypeName} id) => id.Value; - - {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "//" : "")}[return: NotNullIfNotNull(""value"")] - {(existingComponents.HasFlags(IdTypeComponents.NullableConvertToOperator) ? "//" : "")}public static implicit operator {idTypeName}?({underlyingTypeFullyQualifiedName}? value) => value is null ? ({idTypeName}?)null : new {idTypeName}(value{(underlyingTypeIsStruct ? ".Value" : "")}); - {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "//" : "")}{(underlyingTypeIsStruct || isNonNullString ? @"[return: NotNullIfNotNull(""id"")]" : "[return: MaybeNull]")} - {(existingComponents.HasFlags(IdTypeComponents.NullableConvertFromOperator) ? "//" : "")}public static implicit operator {underlyingTypeFullyQualifiedName}?({idTypeName}? id) => id?.Value; + #endregion #region Formatting & Parsing @@ -813,6 +835,7 @@ private sealed record Generatable 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 UnderlyingTypeIsInterface { get => this._bits.GetBit(14); set => this._bits.SetBit(14, value); } public Accessibility Accessibility { get; set; } public IdTypeComponents ExistingComponents { get; set; } public Diagnostic? Problem { get; set; } diff --git a/DomainModeling.Generator/TypeSymbolExtensions.cs b/DomainModeling.Generator/TypeSymbolExtensions.cs index 59da690..3ab95ed 100644 --- a/DomainModeling.Generator/TypeSymbolExtensions.cs +++ b/DomainModeling.Generator/TypeSymbolExtensions.cs @@ -417,7 +417,7 @@ public static bool IsNullable(this ITypeSymbol typeSymbol, out ITypeSymbol under } /// - /// Returns whether the is a , where T matches . + /// Returns whether the is a with T matching . /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsNullableOf(this ITypeSymbol typeSymbol, ITypeSymbol underlyingType) @@ -426,6 +426,30 @@ public static bool IsNullableOf(this ITypeSymbol typeSymbol, ITypeSymbol underly return result; } + /// + /// Returns whether the is either (A) a with T matching , + /// or (B) a reference type matching . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNullableOrReferenceOf(this ITypeSymbol typeSymbol, ITypeSymbol nullableType) + { + var result = (nullableType.IsReferenceType && nullableType.Equals(typeSymbol, SymbolEqualityComparer.Default)) || + (IsNullable(typeSymbol, out var comparand) && nullableType.Equals(comparand, SymbolEqualityComparer.Default)); + return result; + } + + /// + /// Returns whether the is either (A) a with T matching , + /// or (B) itself. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNullableOfOrEqualTo(this ITypeSymbol typeSymbol, ITypeSymbol underlyingType) + { + var result = underlyingType.Equals(typeSymbol, SymbolEqualityComparer.Default) || + (IsNullable(typeSymbol, out var comparand) && underlyingType.Equals(comparand, SymbolEqualityComparer.Default)); + return result; + } + /// /// /// Returns whether the implements any or interface. diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index 4d0ae85..40e6e04 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -120,7 +120,9 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); existingComponents |= ValueObjectTypeComponents.StringComparison.If(members.Any(member => - member.Name == "StringComparison" && member.IsOverride)); + member is IPropertySymbol { Name: "StringComparison", IsImplicitlyDeclared: false, } prop)); + + existingComponents |= ValueObjectTypeComponents.ValueObjectBaseClass.If(type.IsOrInheritsClass(type => type.IsType("ValueObject", "Architect", "DomainModeling", arity: 0), out _)); result.ExistingComponents = existingComponents; @@ -168,7 +170,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable // Require the expected inheritance if (!generatable.IsPartial && !generatable.IsValueObject) { - context.ReportDiagnostic("ValueObjectGeneratorUnexpectedInheritance", "Unexpected inheritance", + context.ReportDiagnostic("ValueObjectGeneratorMissingInterface", "Missing IValueObject interface", "Type marked as value object lacks IValueObject interface. Did you forget the 'partial' keyword and elude source generation?", DiagnosticSeverity.Warning, type); return; } @@ -185,14 +187,6 @@ private static void GenerateSource(SourceProductionContext context, (Generatable 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) { @@ -234,6 +228,18 @@ private static void GenerateSource(SourceProductionContext context, (Generatable DiagnosticSeverity.Warning, member.Member); } + var hasStringProperties = dataMembers.Any(member => member is { Member: IPropertySymbol { Type.SpecialType: SpecialType.System_String } }); + var stringComparisonProperty = (existingComponents.HasFlags(ValueObjectTypeComponents.ValueObjectBaseClass), hasStringProperties, existingComponents.HasFlags(ValueObjectTypeComponents.StringComparison)) switch + { + (false, false, _) => @"", // No strings + (false, true, false) => @"private StringComparison StringComparison => StringComparison.Ordinal;", + (false, true, true) => @"//private StringComparison StringComparison => StringComparison.Ordinal;", + (true, false, false) => @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");", + (true, false, true) => @"//protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");", + (true, true, false) => @"protected sealed override StringComparison StringComparison => StringComparison.Ordinal;", + (true, true, true) => @"//protected sealed override StringComparison StringComparison => StringComparison.Ordinal;", + }; + var toStringExpressions = dataMembers .Select(tuple => $"{tuple.Member.Name}={{this.{tuple.Member.Name}}}") .ToList(); @@ -270,14 +276,12 @@ private static void GenerateSource(SourceProductionContext context, (Generatable namespace {containingNamespace} {{ - [CompilerGenerated] {type.DeclaredAccessibility.ToCodeString()} sealed partial{(isRecord ? " record" : "")} class {typeName} : - ValueObject, + [CompilerGenerated] {type.DeclaredAccessibility.ToCodeString()} sealed partial {(isRecord ? "record " : "")}class {typeName} : + IValueObject, IEquatable<{typeName}>{(isComparable ? "" : "/*")}, IComparable<{typeName}>{(isComparable ? "" : "*/")} {{ - {(isRecord || existingComponents.HasFlags(ValueObjectTypeComponents.StringComparison) ? "//" : "")}{(dataMembers.Any(member => member.Type.SpecialType == SpecialType.System_String) - ? @"protected sealed override StringComparison StringComparison => StringComparison.Ordinal;" - : @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");")} + {stringComparisonProperty} {(existingComponents.HasFlags(ValueObjectTypeComponents.DefaultConstructor) ? "/*" : "")} #pragma warning disable CS8618 // Deserialization constructor @@ -391,6 +395,7 @@ private enum ValueObjectTypeComponents : ulong LessEqualsOperator = 1 << 12, StringComparison = 1 << 13, DefaultConstructor = 1 << 14, + ValueObjectBaseClass = 1 << 15, } private sealed record Generatable diff --git a/DomainModeling.Generator/ValueWrapperGenerator.cs b/DomainModeling.Generator/ValueWrapperGenerator.cs index 42493ec..6f7bd39 100644 --- a/DomainModeling.Generator/ValueWrapperGenerator.cs +++ b/DomainModeling.Generator/ValueWrapperGenerator.cs @@ -1,4 +1,5 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Microsoft.CodeAnalysis; @@ -28,7 +29,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) this.WrapperValueObjectGenerator.Generate(context, valueWrappers); } - internal static string GetCoreTypeFullyQualifiedName( + /// + /// Returns the direct parent of the given wrapper's core type. + /// For example, if the type is simply a direct wrapper, this method returns its own data, but otherwise, it returns whatever is the direct parent of the core type. + /// + internal static BasicGeneratable GetDirectParentOfCoreType( ImmutableArray valueWrappers, string typeName, string containingNamespace) { @@ -36,7 +41,7 @@ internal static string GetCoreTypeFullyQualifiedName( Span initialFullyQualifiedTypeName = stackalloc char[containingNamespace.Length + 1 + typeName.Length]; initialFullyQualifiedTypeName = [.. containingNamespace, '.', .. typeName]; - var result = (string?)null; + ref readonly var result = ref Unsafe.NullRef(); var nextTypeName = (ReadOnlySpan)initialFullyQualifiedTypeName; bool couldDigDeeper; @@ -52,14 +57,24 @@ internal static string GetCoreTypeFullyQualifiedName( nextTypeName[item.ContainingNamespace.Length] == '.') { couldDigDeeper = true; - result = item.CustomCoreTypeFullyQualifiedName ?? item.UnderlyingTypeFullyQualifiedName; - nextTypeName = result.AsSpan(); + result = ref item; + nextTypeName = (item.CustomCoreTypeFullyQualifiedName ?? item.UnderlyingTypeFullyQualifiedName).AsSpan(); break; } } } while (couldDigDeeper); - return result ?? initialFullyQualifiedTypeName.ToString(); + return Unsafe.IsNullRef(ref Unsafe.AsRef(result)) + ? default + : result; + } + + internal static string GetCoreTypeFullyQualifiedName( + ImmutableArray valueWrappers, + string typeName, string containingNamespace) + { + var directParentOfCoreType = GetDirectParentOfCoreType(valueWrappers, typeName, containingNamespace); + return directParentOfCoreType.CustomCoreTypeFullyQualifiedName ?? directParentOfCoreType.UnderlyingTypeFullyQualifiedName; } // ATTENTION: This method cannot be combined with the other recursive one, because this one's results are affected by intermediate items, not just the deepest item @@ -67,10 +82,11 @@ internal static string GetCoreTypeFullyQualifiedName( /// Utility method that recursively determines which formatting and parsing interfaces are supported, based on all known value wrappers. /// This allows even nested value wrappers to dig down into the deepest underlying type. /// - internal static (bool isSpanFormattable, bool isSpanParsable, bool isUtf8SpanFormattable, bool isUtf8SpanParsable) GetFormattabilityAndParsabilityRecursively( + internal static (bool coreValueIsNonNull, bool isSpanFormattable, bool isSpanParsable, bool isUtf8SpanFormattable, bool isUtf8SpanParsable) GetFormattabilityAndParsabilityRecursively( ImmutableArray valueWrappers, string typeName, string containingNamespace) { + var coreValueCouldBeNull = false; var isSpanFormattable = false; var isSpanParsable = false; var isUtf8SpanFormattable = false; @@ -97,6 +113,7 @@ internal static (bool isSpanFormattable, bool isSpanParsable, bool isUtf8SpanFor { couldDigDeeper = true; nextTypeName = item.UnderlyingTypeFullyQualifiedName.AsSpan(); + coreValueCouldBeNull |= item.CoreValueCouldBeNull; isSpanFormattable |= item.IsSpanFormattable; isSpanParsable |= item.IsSpanParsable; isUtf8SpanFormattable |= item.IsUtf8SpanFormattable; @@ -106,7 +123,7 @@ internal static (bool isSpanFormattable, bool isSpanParsable, bool isUtf8SpanFor } } while (couldDigDeeper && (isSpanFormattable & isSpanParsable & isUtf8SpanFormattable & isUtf8SpanParsable) == false); // Possible & worth seeking deeper - return (isSpanFormattable, isSpanParsable, isUtf8SpanFormattable, isUtf8SpanParsable); + return (!coreValueCouldBeNull, isSpanFormattable, isSpanParsable, isUtf8SpanFormattable, isUtf8SpanParsable); } [StructLayout(LayoutKind.Auto)] @@ -118,9 +135,14 @@ internal readonly record struct BasicGeneratable public string UnderlyingTypeFullyQualifiedName { get; } /// /// Set only if manually chosen by the developer. - /// Helps implement wrappers around unofficial wrapper type, such as a WrapperValueObject<Uri> that pretends its core type is string. + /// Helps implement wrappers around unofficial wrapper types, such as a WrapperValueObject<Uri> that pretends its core type is . /// public string? CustomCoreTypeFullyQualifiedName { get; } + public bool CoreTypeIsStruct { get; } + /// + /// A core Value property declared as non-null is a desirable property to propagate, such as to return a non-null value from a conversion operator. + /// + public bool CoreValueCouldBeNull { get; } public bool IsSpanFormattable { get; } public bool IsSpanParsable { get; } public bool IsUtf8SpanFormattable { get; } @@ -128,24 +150,61 @@ internal readonly record struct BasicGeneratable public BasicGeneratable( bool isIdentity, - string typeName, string containingNamespace, - string underlyingTypeFullyQualifiedName, - string? customCoreTypeFullyQualifiedName, - bool isSpanFormattable, - bool isSpanParsable, - bool isUtf8SpanFormattable, - bool isUtf8SpanParsable) + ITypeSymbol wrapperType, + ITypeSymbol underlyingType, + ITypeSymbol? customCoreType) { + var coreType = customCoreType ?? underlyingType; + this.IsIdentity = isIdentity; - this.TypeName = typeName; + this.TypeName = wrapperType.Name; this.ContainingNamespace = containingNamespace; - this.UnderlyingTypeFullyQualifiedName = underlyingTypeFullyQualifiedName; - this.CustomCoreTypeFullyQualifiedName = customCoreTypeFullyQualifiedName; - this.IsSpanFormattable = isSpanFormattable; - this.IsSpanParsable = isSpanParsable; - this.IsUtf8SpanFormattable = isUtf8SpanFormattable; - this.IsUtf8SpanParsable = isUtf8SpanParsable; + this.UnderlyingTypeFullyQualifiedName = underlyingType.ToString(); + this.CustomCoreTypeFullyQualifiedName = customCoreType?.ToString(); + this.CoreTypeIsStruct = coreType.IsValueType; + this.CoreValueCouldBeNull = !CoreValueIsReachedAsNonNull(wrapperType); + this.IsSpanFormattable = underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }); + this.IsSpanParsable = underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }); + this.IsUtf8SpanFormattable = underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }); + this.IsUtf8SpanParsable = underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => + interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, }); + } + + /// + /// The developer may have implemented the Value as non-null. + /// It is worthwhile to propagate this knowledge through nested types, such as to mark the conversion operator to the core type as non-null. + /// + private static bool CoreValueIsReachedAsNonNull(ITypeSymbol type) + { + // A manual ICoreValueWrapper.Value implementation is leading + // In its absence, it is source-generated based on the regular Value property + + // We look only at the first ICoreValueWrapper interface, since we should only be using one of each + var coreOrDirectValueWrapperInterface = + type.AllInterfaces.FirstOrDefault(interf => + interf is { Arity: 2, Name: "ICoreValueWrapper", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } } } && + interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default)) + ?? + type.AllInterfaces.FirstOrDefault(interf => + interf is { Arity: 2, Name: "IDirectValueWrapper", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } } } && + interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default)); + + if (coreOrDirectValueWrapperInterface is null) + return !type.GetMembers("Value").Any(member => member is IPropertySymbol { NullableAnnotation: not NullableAnnotation.NotAnnotated }); + + // ICoreValueWrapper<,> implements IValueWrapper<,>, which declares the Value property + var valueWrapperInterface = coreOrDirectValueWrapperInterface.Interfaces.Single(interf => interf.Name == "IValueWrapper"); + + var explicitValueMember = type.GetMembers().FirstOrDefault(member => + member.Name.EndsWith(".Value") && + member is IPropertySymbol prop && + prop.ExplicitInterfaceImplementations.Any(prop => valueWrapperInterface.Equals(prop.ContainingType, SymbolEqualityComparer.Default))); + + return explicitValueMember is IPropertySymbol { NullableAnnotation: NullableAnnotation.NotAnnotated }; } } } diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index fc5f60a..af5d2c4 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using Architect.DomainModeling.Generator.Common; using Architect.DomainModeling.Generator.Configurators; using Microsoft.CodeAnalysis; @@ -31,18 +31,10 @@ INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attr ? default : new ValueWrapperGenerator.BasicGeneratable( isIdentity: false, - typeName: type.Name, containingNamespace: type.ContainingNamespace.ToString(), - underlyingTypeFullyQualifiedName: underlyingType.ToString() is string underlyingTypeName ? underlyingTypeName : (underlyingTypeName = null!), - customCoreTypeFullyQualifiedName: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[1].ToString(), - isSpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "ISpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), - isSpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "ISpanParsable", ContainingNamespace.Name: "System", Arity: 1, }), - isUtf8SpanFormattable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "IUtf8SpanFormattable", ContainingNamespace.Name: "System", Arity: 0, }), - isUtf8SpanParsable: underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => - interf is { Name: "IUtf8SpanParsable", ContainingNamespace.Name: "System", Arity: 1, })), + wrapperType: type, + underlyingType: underlyingType, + customCoreType: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[1]), _ => default, }) .Where(generatable => generatable != default) @@ -83,7 +75,7 @@ private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeDat // Require the expected inheritance if (!isPartial && !type.IsOrImplementsInterface(type => type.IsType("IWrapperValueObject", "Architect", "DomainModeling", arity: 1), out _)) - return CreateDiagnostic("WrapperValueObjectGeneratorUnexpectedInheritance", "Unexpected inheritance", + return CreateDiagnostic("WrapperValueObjectGeneratorMissingInterface", "Missing IWrapperValueObject interface", "Type marked as wrapper value object lacks IWrapperValueObject interface. Did you forget the 'partial' keyword and elude source generation?", DiagnosticSeverity.Warning); // Require IDirectValueWrapper @@ -104,16 +96,6 @@ private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeDat if (isPartial) { - // Only if class - if (tds is not ClassDeclarationSyntax) - return CreateDiagnostic("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); - - // Only if non-record - if (type.IsRecord) - return CreateDiagnostic("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); - // Only if non-abstract if (type.IsAbstract) return CreateDiagnostic("WrapperValueObjectGeneratorAbstractType", "Source-generated abstract type", @@ -189,10 +171,10 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella 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.UnderlyingTypeFullyQualifiedName = underlyingType.ToString(); - result.UnderlyingTypeKind = underlyingType.TypeKind; result.UnderlyingTypeIsStruct = underlyingType.IsValueType; result.UnderlyingTypeIsNullable = underlyingType.IsNullable(); result.UnderlyingTypeIsString = underlyingType.SpecialType == SpecialType.System_String; + result.UnderlyingTypeIsInterface = underlyingType.TypeKind == TypeKind.Interface; result.ValueFieldName = type.GetMembers().FirstOrDefault(member => member is IFieldSymbol { Name: "k__BackingField" or "value" or "_value" })?.Name ?? "_value"; // IComparable is implemented on-demand, if the type implements IComparable against itself and the underlying type is self-comparable @@ -212,6 +194,9 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= WrapperValueObjectTypeComponents.Constructor.If(type.Constructors.Any(ctor => !ctor.IsStatic && ctor.Parameters.Length == 1 && ctor.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); + existingComponents |= WrapperValueObjectTypeComponents.NullableConstructor.If(underlyingType.IsValueType && type.Constructors.Any(ctor => + !ctor.IsStatic && ctor.Parameters.Length == 1 && ctor.Parameters[0].Type.IsNullableOf(underlyingType))); + existingComponents |= WrapperValueObjectTypeComponents.DefaultConstructor.If(type.Constructors.Any(ctor => !ctor.IsStatic && ctor.Parameters.Length == 0 && ctor.DeclaringSyntaxReferences.Length > 0)); @@ -251,23 +236,23 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= WrapperValueObjectTypeComponents.GreaterThanOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= WrapperValueObjectTypeComponents.LessThanOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= WrapperValueObjectTypeComponents.GreaterEqualsOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= WrapperValueObjectTypeComponents.LessEqualsOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= WrapperValueObjectTypeComponents.ConvertToOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && @@ -279,17 +264,15 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default) && method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); - // Consider having a reference-typed underlying type as already having the operator (though actually it does not apply at all) - existingComponents |= WrapperValueObjectTypeComponents.NullableConvertToOperator.If(!underlyingType.IsValueType || members.Any(member => + existingComponents |= WrapperValueObjectTypeComponents.NullableConvertToOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && - method.ReturnType.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[0].Type.IsNullableOf(underlyingType))); + method.ReturnType.IsNullableOrReferenceOf(type) && + method.Parameters[0].Type.IsNullableOrReferenceOf(underlyingType))); - // Consider having a reference-typed underlying type as already having the operator (though actually it does not apply at all) - existingComponents |= WrapperValueObjectTypeComponents.NullableConvertFromOperator.If(!underlyingType.IsValueType || members.Any(member => + existingComponents |= WrapperValueObjectTypeComponents.NullableConvertFromOperator.If(members.Any(member => member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && - method.ReturnType.IsNullableOf(underlyingType) && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); + method.ReturnType.IsNullableOrReferenceOf(underlyingType) && + method.Parameters[0].Type.IsNullableOrReferenceOf(type))); existingComponents |= WrapperValueObjectTypeComponents.SerializeToUnderlying.If(members.Any(member => member.HasNameOrExplicitInterfaceImplementationName("Serialize") && member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 0, } method && @@ -307,7 +290,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella attribute.AttributeClass?.IsType("JsonConverterAttribute", "Newtonsoft", "Json") == true)); existingComponents |= WrapperValueObjectTypeComponents.StringComparison.If(members.Any(member => - member.Name == "StringComparison" && member.IsOverride)); + member is IPropertySymbol { Name: "StringComparison", IsImplicitlyDeclared: false, } prop)); existingComponents |= WrapperValueObjectTypeComponents.FormattableToStringOverride.If(members.Any(member => member.HasNameOrExplicitInterfaceImplementationName("ToString") && member is IMethodSymbol { Arity: 0, IsStatic: false, Parameters.Length: 2, } method && @@ -379,6 +362,8 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2) && !interf.IsImplicitlyDeclared && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default))); + existingComponents |= WrapperValueObjectTypeComponents.WrapperBaseClass.If(type.IsOrInheritsClass(type => type.IsType("WrapperValueObject", "Architect", "DomainModeling", arity: 1), out _)); + result.ExistingComponents = existingComponents; result.ValueMemberLocation = members.FirstOrDefault(member => member.Name == "Value" && member is IFieldSymbol or IPropertySymbol)?.Locations.FirstOrDefault(); result.IsToStringNullable = underlyingType.IsToStringNullable() || result.ToStringExpression.Contains('?'); @@ -404,19 +389,41 @@ private static void GenerateSource(SourceProductionContext context, (Generatable if (generatable.Problem is not null || !generatable.IsPartial) return; - var coreTypeFullyQualifiedName = ValueWrapperGenerator.GetCoreTypeFullyQualifiedName(valueWrappers, generatable.TypeName, generatable.ContainingNamespace); - - (var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( - valueWrappers, - typeName: generatable.TypeName, containingNamespace: generatable.ContainingNamespace); - var typeName = generatable.TypeName; var containingNamespace = generatable.ContainingNamespace; - var underlyingTypeFullyQualifiedName = generatable.UnderlyingTypeFullyQualifiedName; var valueFieldName = generatable.ValueFieldName; var isComparable = generatable.IsComparable; var existingComponents = generatable.ExistingComponents; + var directParentOfCore = ValueWrapperGenerator.GetDirectParentOfCoreType(valueWrappers, generatable.TypeName, generatable.ContainingNamespace); + var coreTypeFullyQualifiedName = directParentOfCore.CustomCoreTypeFullyQualifiedName ?? directParentOfCore.UnderlyingTypeFullyQualifiedName ?? generatable.UnderlyingTypeFullyQualifiedName; + var coreTypeIsStruct = directParentOfCore.CoreTypeIsStruct; + + (var coreValueIsNonNull, var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( + valueWrappers, + typeName: generatable.TypeName, containingNamespace: generatable.ContainingNamespace); + + var underlyingTypeFullyQualifiedNameForAlias = generatable.UnderlyingTypeFullyQualifiedName; + var coreTypeFullyQualifiedNameForAlias = coreTypeFullyQualifiedName; + var underlyingTypeFullyQualifiedName = Char.IsUpper(underlyingTypeFullyQualifiedNameForAlias[0]) && !underlyingTypeFullyQualifiedNameForAlias.Contains('<') + ? underlyingTypeFullyQualifiedNameForAlias.Split('.').Last() + : underlyingTypeFullyQualifiedNameForAlias; + coreTypeFullyQualifiedName = coreTypeFullyQualifiedNameForAlias == underlyingTypeFullyQualifiedNameForAlias + ? underlyingTypeFullyQualifiedName + : Char.IsUpper(coreTypeFullyQualifiedName[0]) && !coreTypeFullyQualifiedName.Contains('<') + ? coreTypeFullyQualifiedName.Split('.').Last() + : coreTypeFullyQualifiedName; + + var stringComparisonProperty = (existingComponents.HasFlags(WrapperValueObjectTypeComponents.WrapperBaseClass), generatable.UnderlyingTypeIsString, existingComponents.HasFlags(WrapperValueObjectTypeComponents.StringComparison)) switch + { + (false, false, _) => @"", // No strings + (false, true, false) => @"private StringComparison StringComparison => StringComparison.Ordinal;", + (false, true, true) => @"//private StringComparison StringComparison => StringComparison.Ordinal;", + (true, false, false) => @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");", + (true, false, true) => @"//protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");", + (true, true, _) => @"", // Compiler will indicate that override is required + }; + var formattableParsableWrapperSuffix = generatable.UnderlyingTypeIsString ? $"StringWrapper<{typeName}>" : $"Wrapper<{typeName}, {underlyingTypeFullyQualifiedName}>"; @@ -434,6 +441,8 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System.Runtime.CompilerServices; using Architect.DomainModeling; using Architect.DomainModeling.Conversions; +{(underlyingTypeFullyQualifiedName != underlyingTypeFullyQualifiedNameForAlias ? $"using {underlyingTypeFullyQualifiedName} = {underlyingTypeFullyQualifiedNameForAlias};" : "")} +{(coreTypeFullyQualifiedName != coreTypeFullyQualifiedNameForAlias && coreTypeFullyQualifiedName != underlyingTypeFullyQualifiedName ? $"using {coreTypeFullyQualifiedName} = {coreTypeFullyQualifiedNameForAlias};" : "")} #nullable enable @@ -441,8 +450,8 @@ namespace {containingNamespace} {{ {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} - [CompilerGenerated] {generatable.Accessibility.ToCodeString()} sealed partial{(generatable.IsRecord ? " record" : "")} class {typeName} : - WrapperValueObject<{underlyingTypeFullyQualifiedName}>, + [CompilerGenerated] {generatable.Accessibility.ToCodeString()} {(generatable.IsClass ? "sealed" : "readonly")} partial {(generatable.IsRecord ? "record " : "")}{(generatable.IsClass ? "class" : "struct")} {typeName} : + IWrapperValueObject<{underlyingTypeFullyQualifiedName}>, IEquatable<{typeName}>, {(isComparable ? "" : "//")}IComparable<{typeName}>, {(isSpanFormattable ? "" : "//")}ISpanFormattable, ISpanFormattable{formattableParsableWrapperSuffix}, @@ -452,9 +461,7 @@ namespace {containingNamespace} IDirectValueWrapper<{typeName}, {underlyingTypeFullyQualifiedName}>, ICoreValueWrapper<{typeName}, {coreTypeFullyQualifiedName}> {{ - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.StringComparison) ? "/*" : "")} - {(generatable.UnderlyingTypeIsString ? "" : @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.StringComparison) ? "*/" : "")} + {stringComparisonProperty} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Value) ? "/*" : "")} public {underlyingTypeFullyQualifiedName} Value {{ get; private init; }} @@ -467,15 +474,119 @@ namespace {containingNamespace} }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.Constructor) ? "*/" : "")} + {(generatable.UnderlyingCanBeNull || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConstructor) ? "/*" : "")} + /// + /// Accepts a nullable parameter, but throws for null values. + /// For example, this is useful for a mandatory request input where omission must lead to rejection. + /// + public {typeName}({underlyingTypeFullyQualifiedName}? value) + : this(value ?? throw new ArgumentNullException(nameof(value))) + {{ + }} + {(generatable.UnderlyingCanBeNull || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConstructor) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DefaultConstructor) ? "/*" : "")} #pragma warning disable CS8618 // Deserialization constructor + /// + /// Obsolete: This constructor exists for deserialization purposes only. + /// [Obsolete(""This constructor exists for deserialization purposes only."")] - private {typeName}() + {(generatable.IsClass ? "private" : "public")} {typeName}() {{ }} #pragma warning restore CS8618 {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DefaultConstructor) ? "*/" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ToStringOverride) ? "/*" : "")} + public {(generatable.IsClass ? "sealed " : "")}override string{(generatable.IsToStringNullable ? "?" : "")} ToString() + {{ + {(generatable.ToStringExpression.Contains('?') ? "// Null-safety protects instances produced by GetUninitializedObject()" : "")} + return {generatable.ToStringExpression}; + }} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ToStringOverride) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GetHashCodeOverride) ? "/*" : "")} + public {(generatable.IsClass ? "sealed " : "")} override int GetHashCode() + {{ +#pragma warning disable RS1024 // Compare symbols correctly + {(generatable.HashCodeExpression.Contains('?') ? "// Null-safety protects instances produced by GetUninitializedObject()" : "")} + return {generatable.HashCodeExpression}; +#pragma warning restore RS1024 // Compare symbols correctly + }} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GetHashCodeOverride) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOverride) ? "/*" : "")} + public {(generatable.IsClass ? "sealed " : "")} override bool Equals(object? other) + {{ + return other is {typeName} otherValue && this.Equals(otherValue); + }} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOverride) ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsMethod) ? "/*" : "")} + public bool Equals({typeName}{(generatable.IsClass ? "?" : "")} other) + {{ + return {(!generatable.IsClass ? "" : @"other is null + ? false + : ")}{generatable.EqualityExpression}; + }} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsMethod) ? " */" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) || !isComparable ? "/*" : "")} + public int CompareTo({typeName}{(generatable.IsClass ? "?" : "")} other) + {{ + return {(!generatable.IsClass ? "" : @"other is null + ? +1 + : ")}{generatable.ComparisonExpression}; + }} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) || !isComparable ? "*/" : "")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOperator) ? "//" : "")}public static bool operator ==({typeName}{(generatable.IsClass ? "?" : "")} left, {typeName}{(generatable.IsClass ? "?" : "")} right) => {(generatable.IsClass ? "left is null ? right is null : left.Equals(right)" : "left.Equals(right)")}; + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NotEqualsOperator) ? "//" : "")}public static bool operator !=({typeName}{(generatable.IsClass ? "?" : "")} left, {typeName}{(generatable.IsClass ? "?" : "")} right) => !(left == right); + + {(isComparable ? "" : "/*")} + // Nullable comparison operators circumvent the unexpected behavior that would be caused by .NET's lifting + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({typeName}? left, {typeName}? right) => left is {{ }} one && !(right is {{ }} two && one.CompareTo(two) <= 0); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({typeName}? left, {typeName}? right) => right is {{ }} two && !(left is {{ }} one && one.CompareTo(two) >= 0); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({typeName}? left, {typeName}? right) => !(left < right); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({typeName}? left, {typeName}? right) => !(left > right); + {(isComparable ? "" : "*/")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } or { IsClass: true, UnderlyingCanBeNull: true, } + ? "" + : $"public static explicit operator {typeName}({underlyingTypeFullyQualifiedName} value) => new {typeName}(value);")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertFromOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } or { IsClass: true, UnderlyingCanBeNull: true, } + ? "" + : $"public static implicit operator {underlyingTypeFullyQualifiedName}({typeName} instance) => instance.Value;")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } or { UnderlyingTypeIsNullable: true } + ? "" + : @"[return: NotNullIfNotNull(nameof(value))]")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } or { UnderlyingTypeIsNullable: true } + ? "" + : $@"public static explicit operator {typeName}?({underlyingTypeFullyQualifiedName}? value) => value is {{ }} actual ? new {typeName}(actual) : ({typeName}?)null;")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } or { UnderlyingTypeIsNullable: true } + ? "" + : @"[return: NotNullIfNotNull(nameof(instance))]")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } or { UnderlyingTypeIsNullable: true } + ? "" + : $@"public static implicit operator {underlyingTypeFullyQualifiedName}?({typeName}? instance) => instance?.Value;")} + + {(coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "/* For nested wrapper types only" : "")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "//" : "")}{(generatable.IsClass && !coreTypeIsStruct + ? "" + : $"public static explicit operator {typeName}({coreTypeFullyQualifiedName} value) => ValueWrapperUnwrapper.Wrap<{typeName}, {coreTypeFullyQualifiedName}>(value);")} + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertFromOperator) ? "//" : "")}{(generatable.IsClass && !coreTypeIsStruct + ? "" + : $"public static implicit operator {coreTypeFullyQualifiedName}{(coreTypeIsStruct || coreValueIsNonNull ? "" : "?")}({typeName} instance) => ValueWrapperUnwrapper.Unwrap<{typeName}, {coreTypeFullyQualifiedName}>(instance){(coreValueIsNonNull ? "!" : "")};")} + + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "//" : "")}[return: NotNullIfNotNull(nameof(value))] + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "//" : "")}public static explicit operator {typeName}?({coreTypeFullyQualifiedName}? value) => value is {{ }} actual ? ValueWrapperUnwrapper.Wrap<{typeName}, {coreTypeFullyQualifiedName}>(actual) : ({typeName}?)null; + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "//" : "")}[return: {(coreTypeIsStruct || coreValueIsNonNull ? @"NotNullIfNotNull(nameof(instance))" : @"MaybeNull")}] + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "//" : "")}public static implicit operator {coreTypeFullyQualifiedName}?({typeName}? instance) => instance is {{ }} actual ? ValueWrapperUnwrapper.Unwrap<{typeName}, {coreTypeFullyQualifiedName}>(actual) : ({coreTypeFullyQualifiedName}?)null; + {(coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "*/" : "")} + + #region Wrapping & Serialization + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CreateMethod) ? "/*" : "")} [MethodImpl(MethodImplOptions.AggressiveInlining)] static {typeName} IValueWrapper<{typeName}, {underlyingTypeFullyQualifiedName}>.Create({underlyingTypeFullyQualifiedName} value) @@ -516,9 +627,9 @@ namespace {containingNamespace} }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} - {(generatable.ExistingComponents.HasFlags(WrapperValueObjectTypeComponents.CoreValueWrapperInterface) ? "/* Core manually specified" : coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "/* For nested wrapper types only" : "")} + {(generatable.ExistingComponents.HasFlags(WrapperValueObjectTypeComponents.CoreValueWrapperInterface) ? "/* Up to developer because core type was customized" : coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "/* For nested wrapper types only" : "")} [MaybeNull] - {coreTypeFullyQualifiedName} IValueWrapper<{typeName}, {coreTypeFullyQualifiedName}>.Value => ValueWrapperUnwrapper.Unwrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(this.Value); + {coreTypeFullyQualifiedName} IValueWrapper<{typeName}, {coreTypeFullyQualifiedName}>.Value => this.Value is {{ }} actual ? ValueWrapperUnwrapper.Unwrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(actual) : default; [MethodImpl(MethodImplOptions.AggressiveInlining)] static {typeName} IValueWrapper<{typeName}, {coreTypeFullyQualifiedName}>.Create({coreTypeFullyQualifiedName} value) @@ -534,8 +645,8 @@ namespace {containingNamespace} [MethodImpl(MethodImplOptions.AggressiveInlining)] {coreTypeFullyQualifiedName} IValueWrapper<{typeName}, {coreTypeFullyQualifiedName}>.Serialize() {{ - return DomainObjectSerializer.Serialize<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>( - DomainObjectSerializer.Serialize<{typeName}, {underlyingTypeFullyQualifiedName}>(this)); + var intermediateValue = DomainObjectSerializer.Serialize<{typeName}, {underlyingTypeFullyQualifiedName}>(this); + return DomainObjectSerializer.Serialize<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(intermediateValue); }} /// @@ -549,69 +660,7 @@ namespace {containingNamespace} }} {(coreTypeFullyQualifiedName == underlyingTypeFullyQualifiedName ? "*/" : generatable.ExistingComponents.HasFlags(WrapperValueObjectTypeComponents.CoreValueWrapperInterface) ? "*/" : "")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ToStringOverride) ? "/*" : "")} - public sealed override string{(generatable.IsToStringNullable ? "?" : "")} ToString() - {{ - {(generatable.ToStringExpression.Contains('?') ? "// Null-safety protects instances produced by GetUninitializedObject()" : "")} - return {generatable.ToStringExpression}; - }} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ToStringOverride) ? "*/" : "")} - - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GetHashCodeOverride) ? "/*" : "")} - public sealed override int GetHashCode() - {{ -#pragma warning disable RS1024 // Compare symbols correctly - {(generatable.HashCodeExpression.Contains('?') ? "// Null-safety protects instances produced by GetUninitializedObject()" : "")} - return {generatable.HashCodeExpression}; -#pragma warning restore RS1024 // Compare symbols correctly - }} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GetHashCodeOverride) ? "*/" : "")} - - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOverride) ? "/*" : "")} - public sealed override bool Equals(object? other) - {{ - return other is {typeName} otherValue && this.Equals(otherValue); - }} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOverride) ? "*/" : "")} - - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsMethod) ? "/*" : "")} - public bool Equals({typeName}? other) - {{ - return other is null - ? false - : {generatable.EqualityExpression}; - }} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsMethod) ? " */" : "")} - - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) || !isComparable ? "/*" : "")} - public int CompareTo({typeName}? other) - {{ - return other is null - ? +1 - : {generatable.ComparisonExpression}; - }} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) || !isComparable ? "*/" : "")} - - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOperator) ? "//" : "")}public static bool operator ==({typeName}? left, {typeName}? right) => left is null ? right is null : left.Equals(right); - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NotEqualsOperator) ? "//" : "")}public static bool operator !=({typeName}? left, {typeName}? right) => !(left == right); - - {(isComparable ? "" : "/*")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({typeName}? left, {typeName}? right) => left is null ? false : left.CompareTo(right) > 0; - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({typeName}? left, {typeName}? right) => left is null ? right is not null : left.CompareTo(right) < 0; - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({typeName}? left, {typeName}? right) => !(left < right); - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({typeName}? left, {typeName}? right) => !(left > right); - {(isComparable ? "" : "*/")} - - {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "//" : "")} - {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? "[return: NotNull]" : @"[return: NotNullIfNotNull(""value"")]")} - {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "//" : "")}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.ConvertFromOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? "[return: NotNull]" : @"[return: NotNullIfNotNull(""instance"")]")} - {(generatable.UnderlyingTypeKind == TypeKind.Interface || existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertFromOperator) ? "//" : "")}public static implicit operator {underlyingTypeFullyQualifiedName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")}({typeName}{(generatable.UnderlyingTypeIsStruct ? "" : "?")} instance) => instance{(generatable.UnderlyingTypeIsStruct ? "" : "?")}.Value; - - {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? @"[return: NotNullIfNotNull(""value"")]" : "")} - {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertToOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? $"public static explicit operator {typeName}?({underlyingTypeFullyQualifiedName}? value) => value is null ? null : new {typeName}(value.Value);" : "")} - {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? @"[return: NotNullIfNotNull(""instance"")]" : "")} - {(generatable.UnderlyingTypeIsNullable || existingComponents.HasFlags(WrapperValueObjectTypeComponents.NullableConvertFromOperator) ? "//" : "")}{(generatable.UnderlyingTypeIsStruct ? $"public static implicit operator {underlyingTypeFullyQualifiedName}?({typeName}? instance) => instance?.Value;" : "")} + #endregion #region Formatting & Parsing @@ -685,40 +734,42 @@ internal enum WrapperValueObjectTypeComponents : ulong 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, - CreateMethod = 1UL << 33, - DirectValueWrapperInterface = 1UL << 34, - CoreValueWrapperInterface = 1UL << 35, + NullableConstructor = 1UL << 2, + ToStringOverride = 1UL << 3, + GetHashCodeOverride = 1UL << 4, + EqualsOverride = 1UL << 5, + EqualsMethod = 1UL << 6, + CompareToMethod = 1UL << 7, + EqualsOperator = 1UL << 8, + NotEqualsOperator = 1UL << 9, + GreaterThanOperator = 1UL << 10, + LessThanOperator = 1UL << 11, + GreaterEqualsOperator = 1UL << 12, + LessEqualsOperator = 1UL << 13, + ConvertToOperator = 1UL << 14, + ConvertFromOperator = 1UL << 15, + NullableConvertToOperator = 1UL << 16, + NullableConvertFromOperator = 1UL << 17, + NewtonsoftJsonConverter = 1UL << 18, + SystemTextJsonConverter = 1UL << 19, + StringComparison = 1UL << 20, + SerializeToUnderlying = 1UL << 21, + DeserializeFromUnderlying = 1UL << 22, + UnsettableValue = 1UL << 23, + DefaultConstructor = 1UL << 24, + FormattableToStringOverride = 1UL << 25, + ParsableTryParseMethod = 1UL << 26, + ParsableParseMethod = 1UL << 27, + SpanFormattableTryFormatMethod = 1UL << 28, + SpanParsableTryParseMethod = 1UL << 29, + SpanParsableParseMethod = 1UL << 30, + Utf8SpanFormattableTryFormatMethod = 1UL << 31, + Utf8SpanParsableTryParseMethod = 1UL << 32, + Utf8SpanParsableParseMethod = 1UL << 33, + CreateMethod = 1UL << 34, + DirectValueWrapperInterface = 1UL << 35, + CoreValueWrapperInterface = 1UL << 36, + WrapperBaseClass = 1UL << 37, } private sealed record Generatable @@ -750,5 +801,8 @@ private sealed record Generatable public string ComparisonExpression { get; set; } = null!; public SimpleLocation? ValueMemberLocation { get; set; } public Diagnostic? Problem { get; set; } + + public bool IsStruct => !this.IsClass; + public bool UnderlyingCanBeNull => !this.UnderlyingTypeIsStruct || this.UnderlyingTypeIsNullable; } } diff --git a/DomainModeling.Tests/Analyzers/ValueObjectMissingStringComparisonAnalyzerTest.cs b/DomainModeling.Tests/Analyzers/ValueObjectMissingStringComparisonAnalyzerTest.cs new file mode 100644 index 0000000..6eca823 --- /dev/null +++ b/DomainModeling.Tests/Analyzers/ValueObjectMissingStringComparisonAnalyzerTest.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Architect.DomainModeling.Tests.Analyzers +{ + namespace ValueObjectTestTypes + { + [SuppressMessage("Design", "ValueObjectGeneratorMissingStringComparison:ValueObject has string members but no StringComparison property", Justification = "Testing presence of warning.")] + [ValueObject] + internal sealed partial class StringValueObject + { + public string? One { get; private init; } + } + } +} diff --git a/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs b/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs new file mode 100644 index 0000000..1b60ea2 --- /dev/null +++ b/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; +using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; + +namespace Architect.DomainModeling.Tests.Analyzers; + +[SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] +[SuppressMessage("Design", "WrapperValueObjectDefaultExpression:Default expression instantiating unvalidated value object", Justification = "Testing presence of warning.")] +public class WrapperValueObjectDefaultExpressionAnalyzerTests +{ + // Unfortunately, we always get "unnecessary suppression" even when the warning is successfully suppressed + // All we can do is manually outcomment the suppression temporarily to check that each statement in this file still warns + + public static void UseDefaultExpressionOnWrapperValueObjectStruct_Always_ShouldWarn() + { + _ = default(DecimalValue); + } + + public static void UseDefaultLiteralOnWrapperValueObjectStruct_Always_ShouldWarn() + { + DecimalValue value = default; + _ = value; + } +} diff --git a/DomainModeling.Tests/Analyzers/WrapperValueObjectMissingStringComparisonAnalyzerTest.cs b/DomainModeling.Tests/Analyzers/WrapperValueObjectMissingStringComparisonAnalyzerTest.cs new file mode 100644 index 0000000..b3e5c30 --- /dev/null +++ b/DomainModeling.Tests/Analyzers/WrapperValueObjectMissingStringComparisonAnalyzerTest.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Architect.DomainModeling.Tests.Analyzers +{ + namespace WrapperValueObjectTestTypes + { + [SuppressMessage("Design", "WrapperValueObjectGeneratorMissingStringComparison:WrapperValueObject has string members but no StringComparison property", Justification = "Testing presence of warning.")] + [WrapperValueObject] + internal sealed partial class StringWrapperValueObject + { + } + } +} diff --git a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs index 41f3ca2..458f421 100644 --- a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs @@ -405,7 +405,7 @@ public StringIdEntity(SomeStringId id) namespace EnumerableComparerTestTypes { [WrapperValueObject] - public sealed partial class StringWrapperValueObject : IComparable + public sealed partial class StringWrapperValueObject : WrapperValueObject, IComparable { protected sealed override StringComparison StringComparison { get; } diff --git a/DomainModeling.Tests/DomainModeling.Tests.csproj b/DomainModeling.Tests/DomainModeling.Tests.csproj index f27ed2f..cd262e9 100644 --- a/DomainModeling.Tests/DomainModeling.Tests.csproj +++ b/DomainModeling.Tests/DomainModeling.Tests.csproj @@ -37,6 +37,7 @@ + diff --git a/DomainModeling.Tests/DummyBuilderTests.cs b/DomainModeling.Tests/DummyBuilderTests.cs index 735eabb..1789255 100644 --- a/DomainModeling.Tests/DummyBuilderTests.cs +++ b/DomainModeling.Tests/DummyBuilderTests.cs @@ -123,8 +123,10 @@ public Amount(decimal value, string moreComplexConstructor) } [ValueObject] - public sealed partial class Money + public sealed partial class Money : ValueObject { + protected override StringComparison StringComparison => StringComparison.Ordinal; + public string Currency { get; private init; } public Amount Amount { get; private init; } diff --git a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs index abb434f..4eea737 100644 --- a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs +++ b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs @@ -243,7 +243,7 @@ private EntityForEF() [WrapperValueObject] internal sealed partial class Wrapper1ForEF { - protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; + private StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; /// /// This lets us test if a constructor is used or not. @@ -260,7 +260,7 @@ public Wrapper1ForEF(string value) } [WrapperValueObject] -internal sealed partial class Wrapper2ForEF +internal sealed partial class Wrapper2ForEF : WrapperValueObject { /// /// This lets us test if a constructor is used or not. diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index b8533b8..cda3472 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -162,6 +162,48 @@ public void EqualityOperator_WithIgnoreCaseString_ShouldMatchEquals(string one, Assert.Equal(left.Equals(right), left == right); } + [Fact] + public void EqualityOperator_WithNullables_ShouldReturnExpectedResult() + { +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable IDE0004 // Deliberate casts to test specific operators +#pragma warning disable CS8073 // Deliberate casts to test specific operators + Assert.True((StringId?)null == (StringId?)null); + Assert.True((IntId?)null == (IntId?)null); + + Assert.False((StringId?)null == (StringId?)""); + Assert.False((IntId?)null == (IntId?)0); + Assert.False((StringId?)"" == (StringId?)null); + Assert.False((IntId?)0 == (IntId?)null); + + Assert.True((StringId?)"" == (StringId?)""); + Assert.True((IntId?)0 == (IntId?)0); +#pragma warning restore CS8073 +#pragma warning restore IDE0004 +#pragma warning restore IDE0079 + } + + [Fact] + public void InequalityOperator_WithNullables_ShouldReturnExpectedResult() + { +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable IDE0004 // Deliberate casts to test specific operators +#pragma warning disable CS8073 // Deliberate casts to test specific operators + Assert.False((StringId?)null != (StringId?)null); + Assert.False((IntId?)null != (IntId?)null); + + Assert.True((StringId?)null != (StringId?)""); + Assert.True((IntId?)null != (IntId?)0); + Assert.True((StringId?)"" != (StringId?)null); + Assert.True((IntId?)0 != (IntId?)null); + + Assert.False((StringId?)"" != (StringId?)""); + Assert.False((IntId?)0 != (IntId?)0); +#pragma warning restore CS8073 +#pragma warning restore IDE0004 +#pragma warning restore IDE0079 + } + [Theory] [InlineData("", "")] [InlineData("A", "A")] @@ -268,8 +310,8 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? on [Theory] [InlineData(null, null, 0)] - [InlineData(null, "", 0)] - [InlineData("", null, 0)] + [InlineData(null, "", 0, -1)] + [InlineData("", null, 0, +1)] [InlineData("", "", 0)] [InlineData("", "A", -1)] [InlineData("A", "", +1)] @@ -277,19 +319,25 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? on [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, int? expectedResultWithNullSensitiveComparison = null) { var left = (StringId)one; var right = (StringId)two; Assert.Equal(expectedResult > 0, left > right); Assert.Equal(expectedResult <= 0, left <= right); + + // Null StringIds are identical to empty-string StringIds + // However, that property does not hold when wrapped in a nullable, of course + expectedResult = expectedResultWithNullSensitiveComparison ?? expectedResult; + Assert.Equal(expectedResult > 0, (StringId?)one > (StringId?)two); + Assert.Equal(expectedResult <= 0, (StringId?)one <= (StringId?)two); } [Theory] [InlineData(null, null, 0)] - [InlineData(null, "", 0)] - [InlineData("", null, 0)] + [InlineData(null, "", 0, -1)] + [InlineData("", null, 0, +1)] [InlineData("", "", 0)] [InlineData("", "A", -1)] [InlineData("A", "", +1)] @@ -297,13 +345,63 @@ public void GreaterThan_WithString_ShouldReturnExpectedResult(string? one, strin [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, int? expectedResultWithNullSensitiveComparison = null) { var left = (StringId)one; var right = (StringId)two; Assert.Equal(expectedResult < 0, left < right); Assert.Equal(expectedResult >= 0, left >= right); + + // Null StringIds are identical to empty-string StringIds + // However, that property does not hold when wrapped in a nullable, of course + expectedResult = expectedResultWithNullSensitiveComparison ?? expectedResult; + Assert.Equal(expectedResult < 0, (StringId?)one < (StringId?)two); + Assert.Equal(expectedResult >= 0, (StringId?)one >= (StringId?)two); + } + + [Theory] + [InlineData(null, null, 0)] + [InlineData(null, 1, -1)] + [InlineData(1, null, +1)] + [InlineData(1, 1, 0)] + [InlineData(1, 2, -1)] + [InlineData(2, 1, +1)] + public void GreaterThan_AsNullableOrNonNullableStruct_ShouldReturnExpectedResult(int? one, int? two, int expectedResult) + { + var left = (DecimalId?)one; + var right = (DecimalId?)two; + + Assert.Equal(expectedResult > 0, left > right); + Assert.Equal(expectedResult <= 0, left <= right); + + if (left is not null && right is not null) + { + Assert.Equal(left > right, (DecimalId)one! > (DecimalId)two!); + Assert.Equal(left <= right, (DecimalId)one! <= (DecimalId)two!); + } + } + + [Theory] + [InlineData(null, null, 0)] + [InlineData(null, 1, -1)] + [InlineData(1, null, +1)] + [InlineData(1, 1, 0)] + [InlineData(1, 2, -1)] + [InlineData(2, 1, +1)] + public void LessThan_AsNullableOrNonNullableStruct_ShouldReturnExpectedResult(int? one, int? two, int expectedResult) + { + var left = (DecimalId?)one; + var right = (DecimalId?)two; + + Assert.Equal(expectedResult < 0, left < right); + Assert.Equal(expectedResult >= 0, left >= right); + + if (left is not null && right is not null) + { + Assert.Equal(left < right, (DecimalId)one! < (DecimalId)two!); + Assert.Equal(left >= right, (DecimalId)one! >= (DecimalId)two!); + } } [Theory] @@ -352,6 +450,45 @@ public void CastFromNullableUnderlyingType_Regularly_ShouldReturnExpectedResult( Assert.Equal(expectedResult, result?.Value); } + [Theory] + [InlineData(null, "")] // String identities specialize null to "" + [InlineData("0", "0")] + [InlineData("1", "1")] + public void CastToCoreType_Regularly_ShouldReturnExpectedResult(string? value, string? expectedResult) + { + var instance = new NestedStringId(new StringId(value)); + + Assert.Equal(expectedResult, (string)instance); + } + + [Theory] + [InlineData(null, null)] + [InlineData("0", "0")] + [InlineData("1", "1")] + public void CastToNullableCoreType_Regularly_ShouldReturnExpectedResult(string? value, string? expectedResult) + { + var instance = value is null ? (NestedStringId?)null : new NestedStringId(new StringId(value)); + + Assert.Equal(expectedResult, (string?)instance); + } + + [Theory] + [InlineData("0", "0")] + [InlineData("1", "1")] + public void CastFromCoreType_Regularly_ShouldReturnExpectedResult(string value, string expectedResult) + { + Assert.Equal(new NestedStringId(new StringId(expectedResult)), (NestedStringId)value); + } + + [Theory] + [InlineData(null, null)] + [InlineData("0", "0")] + [InlineData("1", "1")] + public void CastFromNullableCoreType_Regularly_ShouldReturnExpectedResult(string? value, string? expectedResult) + { + Assert.Equal(expectedResult is null ? (NestedStringId?)null : new NestedStringId(new StringId(expectedResult)), (NestedStringId?)value); + } + [Theory] [InlineData(0)] [InlineData(1)] @@ -397,7 +534,7 @@ public void Create_ViaDirectUnderlyingValueInterface_ShouldReturnExpectedResult( var stringInstance = new StringValue(value.ToString()); Assert.IsType(CreateFromDirectUnderlyingValue(stringInstance)); - Assert.Equal(value.ToString(), CreateFromDirectUnderlyingValue(stringInstance).Value?.Value); + Assert.Equal(value.ToString(), CreateFromDirectUnderlyingValue(stringInstance).Value.Value); } [Theory] @@ -409,7 +546,7 @@ public void Create_ViaCoreValueInterface_ShouldReturnExpectedResult(int value) Assert.Equal(value, CreateFromCoreValue(value).Value?.Value.Value); Assert.IsType(CreateFromCoreValue(value.ToString())); - Assert.Equal(value.ToString(), CreateFromCoreValue(value.ToString()).Value?.Value); + Assert.Equal(value.ToString(), CreateFromCoreValue(value.ToString()).Value.Value); } [Theory] @@ -425,7 +562,7 @@ public void Serialize_ToImmediateUnderlyingType_ShouldReturnExpectedResult(int v IValueWrapper stringInstance = new FormatAndParseTestingStringId(new StringValue(value.ToString())); Assert.IsType(stringInstance.Serialize()); - Assert.Equal(value.ToString(), stringInstance.Serialize()?.Value); + Assert.Equal(value.ToString(), stringInstance.Serialize().Value); } [Theory] @@ -534,7 +671,7 @@ public void Deserialize_FromImmediateUnderlyingType_ShouldReturnExpectedResult(i var stringInstance = new StringValue(value.ToString()); Assert.IsType(Deserialize(stringInstance)); - Assert.Equal(value.ToString(), Deserialize(stringInstance).Value?.Value); + Assert.Equal(value.ToString(), Deserialize(stringInstance).Value.Value); } [Theory] @@ -546,7 +683,7 @@ public void Deserialize_FromCoreType_ShouldReturnExpectedResult(int value) Assert.Equal(value, Deserialize(value).Value?.Value.Value); Assert.IsType(Deserialize(value.ToString())); - Assert.Equal(value.ToString(), Deserialize(value.ToString()).Value?.Value); + Assert.Equal(value.ToString(), Deserialize(value.ToString()).Value.Value); } [Theory] @@ -822,6 +959,11 @@ internal partial struct IgnoreCaseStringId internal StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; } + [IdentityValueObject] + internal partial struct NestedStringId + { + } + [IdentityValueObject] internal readonly partial struct FormatAndParseTestingIntId { @@ -916,27 +1058,6 @@ public FullySelfImplementedIdentity(int value) this.Value = value; } - public static FullySelfImplementedIdentity Create(int value) - { - return new FullySelfImplementedIdentity(value); - } - - /// - /// Serializes a domain object as a plain value. - /// - int IValueWrapper.Serialize() - { - return this.Value; - } - - /// - /// Deserializes a plain value back into a domain object, without using a parameterized constructor. - /// - static FullySelfImplementedIdentity IValueWrapper.Deserialize(int value) - { - return new FullySelfImplementedIdentity() { Value = value }; - } - public override int GetHashCode() { return this.Value.GetHashCode(); @@ -965,8 +1086,8 @@ public override string ToString() public static bool operator ==(FullySelfImplementedIdentity left, FullySelfImplementedIdentity right) => left.Equals(right); public static bool operator !=(FullySelfImplementedIdentity left, FullySelfImplementedIdentity right) => !(left == right); - public static bool operator >(FullySelfImplementedIdentity left, FullySelfImplementedIdentity right) => left.CompareTo(right) > 0; - public static bool operator <(FullySelfImplementedIdentity left, FullySelfImplementedIdentity right) => left.CompareTo(right) < 0; + public static bool operator >(FullySelfImplementedIdentity? left, FullySelfImplementedIdentity? right) => left is { } one && !(right is { } two && one.CompareTo(two) <= 0); + public static bool operator <(FullySelfImplementedIdentity? left, FullySelfImplementedIdentity? right) => right is { } two && !(left is { } one && one.CompareTo(two) >= 0); public static bool operator >=(FullySelfImplementedIdentity left, FullySelfImplementedIdentity right) => left.CompareTo(right) >= 0; public static bool operator <=(FullySelfImplementedIdentity left, FullySelfImplementedIdentity right) => left.CompareTo(right) <= 0; @@ -978,6 +1099,31 @@ public override string ToString() [return: NotNullIfNotNull(nameof(id))] public static implicit operator int?(FullySelfImplementedIdentity? id) => id?.Value; + #region Wrapping & Serialization + + public static FullySelfImplementedIdentity Create(int value) + { + return new FullySelfImplementedIdentity(value); + } + + /// + /// Serializes a domain object as a plain value. + /// + int IValueWrapper.Serialize() + { + return this.Value; + } + + /// + /// Deserializes a plain value back into a domain object, without using a parameterized constructor. + /// + static FullySelfImplementedIdentity IValueWrapper.Deserialize(int value) + { + return new FullySelfImplementedIdentity() { Value = value }; + } + + #endregion + #region Formatting & Parsing //#if !NET10_0_OR_GREATER // Starting from .NET 10, these operations are provided by default implementations and extension methods diff --git a/DomainModeling.Tests/ValueObjectExtensionsTests.cs b/DomainModeling.Tests/ValueObjectExtensionsTests.cs new file mode 100644 index 0000000..e19ed9d --- /dev/null +++ b/DomainModeling.Tests/ValueObjectExtensionsTests.cs @@ -0,0 +1,26 @@ +using Architect.DomainModeling.Tests.IdentityTestTypes; +using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; +using Xunit; + +namespace Architect.DomainModeling.Tests; + +public class ValueObjectExtensionsTests +{ + [Fact] + public void IsDefault_WithDefaultEquivalent_ShouldReturnExpectedResult() + { + var result1 = default(StringId).IsDefault(); + var result2 = new StringId("").IsDefault(); + + Assert.True(result1); + Assert.True(result2); + } + + [Fact] + public void IsDefault_WithoutDefaultEquivalent_ShouldReturnExpectedResult() + { + var result = new StringValue("A").IsDefault(); + + Assert.False(result); + } +} diff --git a/DomainModeling.Tests/ValueObjectTests.cs b/DomainModeling.Tests/ValueObjectTests.cs index e5718af..9c13143 100644 --- a/DomainModeling.Tests/ValueObjectTests.cs +++ b/DomainModeling.Tests/ValueObjectTests.cs @@ -504,7 +504,7 @@ public void ContainsWhitespaceOrNonPrintableCharacters_WithLongInput_ShouldRetur [Fact] public void StringComparison_WithNonStringType_ShouldThrow() { - var instance = new IntValue(default, default); + var instance = new DecimalValue(default, default); Assert.Throws(() => instance.GetStringComparison()); } @@ -1004,58 +1004,6 @@ public void DeserializeWithNewtonsoftJson_WithDecimal_ShouldReturnExpectedResult Assert.Equal(value.Value, result.Two); } } - - private sealed class ManualValueObject : ValueObject - { - public override string ToString() => this.Id.ToString(); - - public int Id { get; } - - public ManualValueObject(int id) - { - this.Id = id; - } - - public static new bool ContainsNonAlphanumericCharacters(ReadOnlySpan text) - { - return ValueObject.ContainsNonAlphanumericCharacters(text); - } - - public static new bool ContainsNonWordCharacters(ReadOnlySpan text) - { - return ValueObject.ContainsNonWordCharacters(text); - } - - public static new bool ContainsNonAsciiCharacters(ReadOnlySpan text) - { - return ValueObject.ContainsNonAsciiCharacters(text); - } - - public static new bool ContainsNonAsciiOrNonPrintableCharacters(ReadOnlySpan text, bool flagNewLinesAndTabs = true) - { - return ValueObject.ContainsNonAsciiOrNonPrintableCharacters(text, flagNewLinesAndTabs); - } - - public static new bool ContainsNonAsciiOrNonPrintableOrWhitespaceCharacters(ReadOnlySpan text) - { - return ValueObject.ContainsNonAsciiOrNonPrintableOrWhitespaceCharacters(text); - } - - public static new bool ContainsNonPrintableCharacters(ReadOnlySpan text, bool flagNewLinesAndTabs) - { - return ValueObject.ContainsNonPrintableCharacters(text, flagNewLinesAndTabs); - } - - public static new bool ContainsNonPrintableCharactersOrDoubleQuotes(ReadOnlySpan text, bool flagNewLinesAndTabs) - { - return ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(text, flagNewLinesAndTabs); - } - - public static new bool ContainsWhitespaceOrNonPrintableCharacters(ReadOnlySpan text) - { - return ValueObject.ContainsWhitespaceOrNonPrintableCharacters(text); - } - } } // Use a namespace, since our source generators dislike nested types @@ -1093,7 +1041,7 @@ public Entity() } [ValueObject] - public sealed partial class IntValue + public sealed partial record class IntValue { [JsonInclude, JsonPropertyName("One"), Newtonsoft.Json.JsonProperty] public int One { get; private init; } @@ -1107,12 +1055,10 @@ public IntValue(int one, int two, object? _ = null) this.One = one; this.Two = two; } - - public StringComparison GetStringComparison() => this.StringComparison; } [ValueObject] - public sealed partial class StringValue : IComparable + public sealed partial class StringValue : ValueObject, IComparable { protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; @@ -1143,11 +1089,15 @@ public DecimalValue(decimal one, decimal two, object? _ = null) this.One = one; this.Two = two; } + + public StringComparison GetStringComparison() => this.StringComparison; } [ValueObject] public sealed partial class DefaultComparingStringValue : IComparable { + private StringComparison StringComparison => StringComparison.Ordinal; + public string? Value { get; private init; } public DefaultComparingStringValue(string? value) @@ -1159,7 +1109,7 @@ public DefaultComparingStringValue(string? value) } [ValueObject] - public sealed partial class ImmutableArrayValueObject + public sealed partial class ImmutableArrayValueObject : ValueObject { protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; @@ -1180,8 +1130,6 @@ public ImmutableArrayValueObject(IEnumerable values) [ValueObject] public sealed partial class ArrayValueObject { - protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; - public string?[]? StringValues { get; private init; } public int?[] IntValues { get; private init; } @@ -1193,7 +1141,7 @@ public ArrayValueObject(string?[]? stringValues, int?[] intValues) } [ValueObject] - public sealed partial class CustomCollectionValueObject + public sealed partial record class CustomCollectionValueObject { public CustomCollection? Values { get; set; } @@ -1224,6 +1172,58 @@ internal sealed partial class EmptyValueObject public override string ToString() => throw new NotSupportedException(); } + public sealed class ManualValueObject : ValueObject + { + public override string ToString() => this.Id.ToString(); + + public int Id { get; } + + public ManualValueObject(int id) + { + this.Id = id; + } + + public static new bool ContainsNonAlphanumericCharacters(ReadOnlySpan text) + { + return ValueObject.ContainsNonAlphanumericCharacters(text); + } + + public static new bool ContainsNonWordCharacters(ReadOnlySpan text) + { + return ValueObject.ContainsNonWordCharacters(text); + } + + public static new bool ContainsNonAsciiCharacters(ReadOnlySpan text) + { + return ValueObject.ContainsNonAsciiCharacters(text); + } + + public static new bool ContainsNonAsciiOrNonPrintableCharacters(ReadOnlySpan text, bool flagNewLinesAndTabs = true) + { + return ValueObject.ContainsNonAsciiOrNonPrintableCharacters(text, flagNewLinesAndTabs); + } + + public static new bool ContainsNonAsciiOrNonPrintableOrWhitespaceCharacters(ReadOnlySpan text) + { + return ValueObject.ContainsNonAsciiOrNonPrintableOrWhitespaceCharacters(text); + } + + public static new bool ContainsNonPrintableCharacters(ReadOnlySpan text, bool flagNewLinesAndTabs) + { + return ValueObject.ContainsNonPrintableCharacters(text, flagNewLinesAndTabs); + } + + public static new bool ContainsNonPrintableCharactersOrDoubleQuotes(ReadOnlySpan text, bool flagNewLinesAndTabs) + { + return ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(text, flagNewLinesAndTabs); + } + + public static new bool ContainsWhitespaceOrNonPrintableCharacters(ReadOnlySpan text) + { + return ValueObject.ContainsWhitespaceOrNonPrintableCharacters(text); + } + } + /// /// Should merely compile. /// diff --git a/DomainModeling.Tests/WrapperValueObjectTests.cs b/DomainModeling.Tests/WrapperValueObjectTests.cs index 516548f..dcf437f 100644 --- a/DomainModeling.Tests/WrapperValueObjectTests.cs +++ b/DomainModeling.Tests/WrapperValueObjectTests.cs @@ -28,11 +28,17 @@ public void StringComparison_WithStringType_ShouldReturnExpectedResult() } [Fact] - public void Construct_WithNull_ShouldThrow() + public void Construct_WithNullReferenceType_ShouldThrow() { Assert.Throws(() => new StringValue(null!)); } + [Fact] + public void Construct_WithNullValueType_ShouldThrow() + { + Assert.Throws(() => new IntValue(null)); + } + [Fact] public void ToString_Regularly_ShouldReturnExpectedResult() { @@ -154,6 +160,48 @@ public void EqualityOperator_WithIgnoreCaseString_ShouldMatchEquals(string one, Assert.Equal(left.Equals(right), left == right); } + [Fact] + public void EqualityOperator_WithNullables_ShouldReturnExpectedResult() + { +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable IDE0004 // Deliberate casts to test specific operators +#pragma warning disable CS8073 // Deliberate casts to test specific operators + Assert.True((StringValue?)null == (StringValue?)null); + Assert.True((DecimalValue?)null == (DecimalValue?) null); + + Assert.False((StringValue?)null == (StringValue?)""); + Assert.False((DecimalValue?)null == (DecimalValue?)0); + Assert.False((StringValue?)"" == (StringValue?)null); + Assert.False((DecimalValue?)0 == (DecimalValue?)null); + + Assert.True((StringValue?)"" == (StringValue?)""); + Assert.True((DecimalValue?)0 == (DecimalValue?)0); +#pragma warning restore CS8073 +#pragma warning restore IDE0004 +#pragma warning restore IDE0079 + } + + [Fact] + public void InequalityOperator_WithNullables_ShouldReturnExpectedResult() + { +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable IDE0004 // Deliberate casts to test specific operators +#pragma warning disable CS8073 // Deliberate casts to test specific operators + Assert.False((StringValue?)null != (StringValue?)null); + Assert.False((DecimalValue?)null != (DecimalValue?)null); + + Assert.True((StringValue?)null != (StringValue?)""); + Assert.True((DecimalValue?)null != (DecimalValue?)0); + Assert.True((StringValue?)"" != (StringValue?)null); + Assert.True((DecimalValue?)0 != (DecimalValue?)null); + + Assert.False((StringValue?)"" != (StringValue?)""); + Assert.False((DecimalValue?)0 != (DecimalValue?)0); +#pragma warning restore CS8073 +#pragma warning restore IDE0004 +#pragma warning restore IDE0079 + } + [Theory] [InlineData("", "")] [InlineData("A", "A")] @@ -202,8 +250,8 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? on var left = (StringValue?)one; var right = (StringValue?)two; - Assert.Equal(expectedResult, Comparer.Default.Compare(left, right)); - Assert.Equal(-expectedResult, Comparer.Default.Compare(right, left)); + Assert.Equal(expectedResult, Comparer.Default.Compare(left, right)); + Assert.Equal(-expectedResult, Comparer.Default.Compare(right, left)); } [Theory] @@ -224,6 +272,12 @@ public void GreaterThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string? Assert.Equal(expectedResult > 0, left > right); Assert.Equal(expectedResult <= 0, left <= right); + + if (left is not null && right is not null) + { + Assert.Equal(left > right, (StringValue)one! > (StringValue)two!); + Assert.Equal(left <= right, (StringValue)one! <= (StringValue)two!); + } } [Theory] @@ -244,6 +298,56 @@ public void LessThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string? one Assert.Equal(expectedResult < 0, left < right); Assert.Equal(expectedResult >= 0, left >= right); + + if (left is not null && right is not null) + { + Assert.Equal(left < right, (StringValue)one! < (StringValue)two!); + Assert.Equal(left >= right, (StringValue)one! >= (StringValue)two!); + } + } + + [Theory] + [InlineData(null, null, 0)] + [InlineData(null, 1, -1)] + [InlineData(1, null, +1)] + [InlineData(1, 1, 0)] + [InlineData(1, 2, -1)] + [InlineData(2, 1, +1)] + public void GreaterThan_AsNullableOrNonNullableStruct_ShouldReturnExpectedResult(int? one, int? two, int expectedResult) + { + var left = (DecimalValue?)one; + var right = (DecimalValue?)two; + + Assert.Equal(expectedResult > 0, left > right); + Assert.Equal(expectedResult <= 0, left <= right); + + if (left is not null && right is not null) + { + Assert.Equal(left > right, (DecimalValue)one! > (DecimalValue)two!); + Assert.Equal(left <= right, (DecimalValue)one! <= (DecimalValue)two!); + } + } + + [Theory] + [InlineData(null, null, 0)] + [InlineData(null, 1, -1)] + [InlineData(1, null, +1)] + [InlineData(1, 1, 0)] + [InlineData(1, 2, -1)] + [InlineData(2, 1, +1)] + public void LessThan_AsNullableOrNonNullableStruct_ShouldReturnExpectedResult(int? one, int? two, int expectedResult) + { + var left = (DecimalValue?)one; + var right = (DecimalValue?)two; + + Assert.Equal(expectedResult < 0, left < right); + Assert.Equal(expectedResult >= 0, left >= right); + + if (left is not null && right is not null) + { + Assert.Equal(left < right, (DecimalValue)one! < (DecimalValue)two!); + Assert.Equal(left >= right, (DecimalValue)one! >= (DecimalValue)two!); + } } [Theory] @@ -292,6 +396,54 @@ public void CastFromNullableUnderlyingType_Regularly_ShouldReturnExpectedResult( Assert.Equal(expectedResult, result?.Value); } + [Theory] + [InlineData(null, null)] + [InlineData(0, 0)] + [InlineData(1, 1)] + public void CastToCoreType_Regularly_ShouldReturnExpectedResult(int? value, int? expectedResult) + { + var intInstance = new NestedIntValue(new IntValue(value ?? 0)); + Assert.Equal(expectedResult ?? 0, (int)intInstance); + + var decimalInstance = value is null ? null : new NestedDecimalValue(new DecimalValue(value.Value)); + if (expectedResult is null) + Assert.Throws(() => (decimal)decimalInstance!); + else + Assert.Equal((decimal)expectedResult, (decimal)decimalInstance!); + } + + [Theory] + [InlineData(null, null)] + [InlineData(0, 0)] + [InlineData(1, 1)] + public void CastToNullableCoreType_Regularly_ShouldReturnExpectedResult(int? value, int? expectedResult) + { + var intInstance = value is null ? (NestedIntValue?)null: new NestedIntValue(new IntValue(value.Value)); + Assert.Equal(expectedResult, (int?)intInstance); + + var decimalInstance = value is null ? null : new NestedDecimalValue(new DecimalValue(value.Value)); + Assert.Equal(expectedResult, (decimal?)decimalInstance); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(1, 1)] + public void CastFromCoreType_Regularly_ShouldReturnExpectedResult(int value, int expectedResult) + { + Assert.Equal(new NestedIntValue(new IntValue(expectedResult)), (NestedIntValue)value); + Assert.Equal(new NestedDecimalValue(new DecimalValue(expectedResult)), (NestedDecimalValue)value); + } + + [Theory] + [InlineData(null, null)] + [InlineData(0, 0)] + [InlineData(1, 1)] + public void CastFromNullableCoreType_Regularly_ShouldReturnExpectedResult(int? value, int? expectedResult) + { + Assert.Equal(expectedResult is null ? null : new NestedIntValue(new IntValue(expectedResult)), (NestedIntValue?)value); + Assert.Equal(expectedResult is null ? null : new NestedDecimalValue(new DecimalValue(expectedResult)), (NestedDecimalValue?)value); + } + [Theory] [InlineData(0)] [InlineData(1)] @@ -337,7 +489,7 @@ public void Create_ViaDirectUnderlyingValueInterface_ShouldReturnExpectedResult( var stringInstance = new FormatAndParseTestingNestedStringWrapper(new FormatAndParseTestingStringId(new StringValue(value.ToString()))); Assert.IsType(CreateFromDirectUnderlyingValue(stringInstance)); - Assert.Equal(value.ToString(), CreateFromDirectUnderlyingValue(stringInstance).Value.Value.Value?.Value); + Assert.Equal(value.ToString(), CreateFromDirectUnderlyingValue(stringInstance).Value.Value.Value.Value); } [Theory] @@ -349,7 +501,7 @@ public void Create_ViaCoreValueInterface_ShouldReturnExpectedResult(int value) Assert.Equal(value, CreateFromCoreValue(value).Value.Value); Assert.IsType(CreateFromCoreValue(value.ToString())); - Assert.Equal(value.ToString(), CreateFromCoreValue(value.ToString()).Value.Value.Value?.Value); + Assert.Equal(value.ToString(), CreateFromCoreValue(value.ToString()).Value.Value.Value.Value); } [Theory] @@ -365,7 +517,7 @@ public void Serialize_ToImmediateUnderlyingType_ShouldReturnExpectedResult(int v IValueWrapper stringInstance = new FormatAndParseTestingStringWrapper(new FormatAndParseTestingNestedStringWrapper(new FormatAndParseTestingStringId(new StringValue(value.ToString())))); Assert.IsType(stringInstance.Serialize()); - Assert.Equal(value.ToString(), stringInstance.Serialize()?.Value.Value?.Value); + Assert.Equal(value.ToString(), stringInstance.Serialize()?.Value.Value.Value); } [Theory] @@ -475,7 +627,7 @@ public void Deserialize_FromImmediateUnderlyingType_ShouldReturnExpectedResult(i var stringInstance = new FormatAndParseTestingNestedStringWrapper(new FormatAndParseTestingStringId(new StringValue(value.ToString()))); Assert.IsType(Deserialize(stringInstance)); - Assert.Equal(value.ToString(), Deserialize(stringInstance).Value.Value.Value?.Value); + Assert.Equal(value.ToString(), Deserialize(stringInstance).Value.Value.Value.Value); } [Theory] @@ -487,7 +639,7 @@ public void Deserialize_FromCoreType_ShouldReturnExpectedResult(int value) Assert.Equal(value, Deserialize(value).Value.Value); Assert.IsType(Deserialize(value.ToString())); - Assert.Equal(value.ToString(), Deserialize(value.ToString()).Value.Value.Value?.Value); + Assert.Equal(value.ToString(), Deserialize(value.ToString()).Value.Value.Value.Value); } [Theory] @@ -499,10 +651,10 @@ public void DeserializeWithSystemTextJson_Regularly_ShouldReturnExpectedResult(s Assert.Equal(value, System.Text.Json.JsonSerializer.Deserialize(json)?.Value); json = json == "null" ? json : $@"""{json}"""; - Assert.Equal(value?.ToString(), System.Text.Json.JsonSerializer.Deserialize(json)?.Value); + 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); + Assert.Equal(value?.ToString(), json == "null" ? null : System.Text.Json.JsonSerializer.Deserialize(json)?.Value.Value.Value); } [Theory] @@ -514,10 +666,10 @@ public void DeserializeWithNewtonsoftJson_Regularly_ShouldReturnExpectedResult(s Assert.Equal(value, Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value); json = json == "null" ? json : $@"""{json}"""; - Assert.Equal(value?.ToString(), Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value); + 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); + Assert.Equal(value?.ToString(), json == "null" ? null : Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value.Value.Value); } /// @@ -533,7 +685,7 @@ public void DeserializeWithSystemTextJson_WithDecimal_ShouldReturnExpectedResult // Attempt to mess with the deserialization, which should have no effect CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("nl-NL"); - Assert.Equal(value, System.Text.Json.JsonSerializer.Deserialize(json)?.Value); + Assert.Equal(value, System.Text.Json.JsonSerializer.Deserialize(json)?.Value); } /// @@ -549,7 +701,7 @@ public void DeserializeWithNewtonsoftJson_WithDecimal_ShouldReturnExpectedResult // Attempt to mess with the deserialization, which should have no effect CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); - Assert.Equal(value, Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value); + Assert.Equal(value, Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value); } [Theory] @@ -668,7 +820,7 @@ public void ParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() 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("5", result4.Value?.Value.Value.Value); Assert.Equal(result4, FormatAndParseTestingStringWrapper.Parse(input, provider: null)); } @@ -690,7 +842,7 @@ public void SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResu 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("5", result4.Value?.Value.Value.Value); Assert.Equal(result4, FormatAndParseTestingStringWrapper.Parse(input, provider: null)); } @@ -712,7 +864,7 @@ public void Utf8SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpected 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("5", result4.Value?.Value.Value.Value); Assert.Equal(result4, FormatAndParseTestingStringWrapper.Parse(input, provider: null)); } @@ -748,12 +900,12 @@ 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 + public readonly partial struct OtherAlreadyPartial { } // Should be recognized in spite of the attribute and the base class to be defined on different partials - public sealed partial class OtherAlreadyPartial : WrapperValueObject + public readonly partial struct OtherAlreadyPartial : IWrapperValueObject { } @@ -771,15 +923,15 @@ public sealed partial class IntValue : WrapperValueObject } [WrapperValueObject] - public sealed partial class StringValue : IComparable + public readonly partial record struct StringValue : IComparable { - protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; + private StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; public StringComparison GetStringComparison() => this.StringComparison; } [WrapperValueObject] - public sealed partial class DecimalValue + public readonly partial struct DecimalValue : IComparable { } @@ -807,7 +959,7 @@ public CustomCollection(string value) /// Should merely compile. /// [WrapperValueObject] - public sealed partial class StringArrayValue + public partial record struct StringArrayValue { } @@ -819,6 +971,16 @@ public sealed partial class DecimalArrayValue : WrapperValueObject { } + [WrapperValueObject] + public partial record struct NestedIntValue + { + } + + [WrapperValueObject] + public partial record class NestedDecimalValue + { + } + [WrapperValueObject] internal partial class FormatAndParseTestingStringWrapper { @@ -923,8 +1085,8 @@ public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnly [WrapperValueObject] [System.Text.Json.Serialization.JsonConverter(typeof(ValueWrapperJsonConverter))] [Newtonsoft.Json.JsonConverter(typeof(ValueWrapperNewtonsoftJsonConverter))] - internal sealed partial class FullySelfImplementedWrapperValueObject - : WrapperValueObject, + internal sealed partial class FullySelfImplementedWrapperValueObject : + IWrapperValueObject, IEquatable, IComparable, ISpanFormattable, @@ -934,8 +1096,6 @@ internal sealed partial class FullySelfImplementedWrapperValueObject IDirectValueWrapper, ICoreValueWrapper { - protected sealed override StringComparison StringComparison => throw new NotSupportedException("This operation applies to string-based value objects only."); - public int Value { get; private init; } public FullySelfImplementedWrapperValueObject(int value) @@ -943,42 +1103,20 @@ public FullySelfImplementedWrapperValueObject(int value) this.Value = value; } - [Obsolete("This constructor exists for deserialization purposes only.")] - private FullySelfImplementedWrapperValueObject() - { - } - - static FullySelfImplementedWrapperValueObject IValueWrapper.Create(int value) - { - return new FullySelfImplementedWrapperValueObject(value); - } - /// - /// Serializes a domain object as a plain value. + /// Accepts a nullable parameter, but throws for null values. + /// For example, this is useful for a mandatory request input where omission must lead to rejection. /// - int IValueWrapper.Serialize() + public FullySelfImplementedWrapperValueObject(int? value) + : this(value ?? throw new ArgumentNullException(nameof(value))) { - return this.Value; } - /// - /// Deserializes a plain value back into a domain object, without using a parameterized constructor. - /// - static FullySelfImplementedWrapperValueObject IValueWrapper.Deserialize(int value) + [Obsolete("This constructor exists for deserialization purposes only.")] + private FullySelfImplementedWrapperValueObject() { -#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary -#pragma warning disable CS0618 // Obsolete constructor is intended for us - return new FullySelfImplementedWrapperValueObject() { Value = value }; -#pragma warning restore CS0618 -#pragma warning restore IDE0079 } - // Manual interface implementation to support custom core value - long IValueWrapper.Value => (long)this.Value; - static FullySelfImplementedWrapperValueObject IValueWrapper.Create(long value) => new FullySelfImplementedWrapperValueObject((int)value); - long IValueWrapper.Serialize() => (long)this.Value; - static FullySelfImplementedWrapperValueObject IValueWrapper.Deserialize(long value) => DomainObjectSerializer.Deserialize((int)value); - public sealed override int GetHashCode() { return this.Value.GetHashCode(); @@ -1022,6 +1160,41 @@ public sealed override string ToString() [return: NotNullIfNotNull(nameof(instance))] public static implicit operator int?(FullySelfImplementedWrapperValueObject? instance) => instance?.Value; + #region Wrapping & Serialization + + static FullySelfImplementedWrapperValueObject IValueWrapper.Create(int value) + { + return new FullySelfImplementedWrapperValueObject(value); + } + + /// + /// Serializes a domain object as a plain value. + /// + int IValueWrapper.Serialize() + { + return this.Value; + } + + /// + /// Deserializes a plain value back into a domain object, without using a parameterized constructor. + /// + static FullySelfImplementedWrapperValueObject IValueWrapper.Deserialize(int value) + { +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable CS0618 // Obsolete constructor is intended for us + return new FullySelfImplementedWrapperValueObject() { Value = value }; +#pragma warning restore CS0618 +#pragma warning restore IDE0079 + } + + // Manual interface implementation to support custom core value + long IValueWrapper.Value => (long)this.Value; + static FullySelfImplementedWrapperValueObject IValueWrapper.Create(long value) => new FullySelfImplementedWrapperValueObject((int)value); + long IValueWrapper.Serialize() => (long)this.Value; + static FullySelfImplementedWrapperValueObject IValueWrapper.Deserialize(long value) => DomainObjectSerializer.Deserialize((int)value); + + #endregion + #region Formatting & Parsing //#if !NET10_0_OR_GREATER // Starting from .NET 10, these operations are provided by default implementations and extension methods diff --git a/DomainModeling.sln b/DomainModeling.sln index 19bbe15..14f94cc 100644 --- a/DomainModeling.sln +++ b/DomainModeling.sln @@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DomainModeling.Analyzer", "DomainModeling.Analyzer\DomainModeling.Analyzer.csproj", "{39B467AB-9E95-47AF-713B-D37A02BD964B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DomainModeling.CodeFixProviders", "DomainModeling.CodeFixProviders\DomainModeling.CodeFixProviders.csproj", "{4BD0ED76-D10B-71B9-CF4B-DC1A45EC5C21}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,6 +51,10 @@ Global {39B467AB-9E95-47AF-713B-D37A02BD964B}.Debug|Any CPU.Build.0 = Debug|Any CPU {39B467AB-9E95-47AF-713B-D37A02BD964B}.Release|Any CPU.ActiveCfg = Release|Any CPU {39B467AB-9E95-47AF-713B-D37A02BD964B}.Release|Any CPU.Build.0 = Release|Any CPU + {4BD0ED76-D10B-71B9-CF4B-DC1A45EC5C21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BD0ED76-D10B-71B9-CF4B-DC1A45EC5C21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BD0ED76-D10B-71B9-CF4B-DC1A45EC5C21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BD0ED76-D10B-71B9-CF4B-DC1A45EC5C21}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DomainModeling/Architect.DomainModeling.targets b/DomainModeling/Architect.DomainModeling.targets index 26b16f6..e81077c 100644 --- a/DomainModeling/Architect.DomainModeling.targets +++ b/DomainModeling/Architect.DomainModeling.targets @@ -1,5 +1,6 @@ + diff --git a/DomainModeling/Comparisons/ValueObjectStringValidator.cs b/DomainModeling/Comparisons/ValueObjectStringValidator.cs new file mode 100644 index 0000000..ee8547a --- /dev/null +++ b/DomainModeling/Comparisons/ValueObjectStringValidator.cs @@ -0,0 +1,504 @@ +using System.Globalization; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Architect.DomainModeling.Comparisons; + +public static class ValueObjectStringValidator +{ + // Note: Most methods in this class expect to reach their final return statement, so they optimize for that case with logical instead of conditional operators, to reduce branching + + /// + /// A vector filled completely with the ASCII null character's value (0). + /// + private static readonly Vector AsciiNullValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)0U, Vector.Count).ToArray())[0]; + /// + /// A vector filled completely with the ' ' (space) character's value (32). + /// + private static readonly Vector SpaceValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)' ', Vector.Count).ToArray())[0]; + /// + /// A vector filled completely with the ASCII zero digit character's value (48). + /// + private static readonly Vector ZeroDigitValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)'0', Vector.Count).ToArray())[0]; + /// + /// A vector filled completely with the ASCII nine digit character's value (57). + /// + private static readonly Vector NineDigitValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)'9', Vector.Count).ToArray())[0]; + /// + /// A vector filled completely with the '_' character's value (95). + /// + private static readonly Vector UnderscoreValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)'_', Vector.Count).ToArray())[0]; + /// + /// A vector filled completely with the 'a' character's value (97). + /// + private static readonly Vector LowercaseAValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)'a', Vector.Count).ToArray())[0]; + /// + /// A vector filled completely with the 'z' character's value (122). + /// + private static readonly Vector LowercaseZValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)'z', Vector.Count).ToArray())[0]; + /// + /// A vector filled completely with the greatest ASCII character's value (127). + /// + private static readonly Vector MaxAsciiValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)SByte.MaxValue, Vector.Count).ToArray())[0]; + /// + /// A vector filled completely with a character that, when binary OR'ed with an ASCII letter, results in the corresponding lowercase letter. + /// + private static readonly Vector ToLowercaseAsciiValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)0b100000U, Vector.Count).ToArray())[0]; + + /// + /// + /// This method detects non-alphanumeric characters. + /// + /// + /// It returns true, unless the given consists exclusively of ASCII letters/digits. + /// + /// + public static bool ContainsNonAlphanumericCharacters(ReadOnlySpan text) + { + var remainder = text.Length; + + // Attempt to process most of the input with SIMD + if (Vector.IsHardwareAccelerated) + { + remainder %= Vector.Count; + + var vectors = MemoryMarshal.Cast>(text[..^remainder]); + + foreach (var vector in vectors) + { + var lowercaseVector = Vector.BitwiseOr(vector, ToLowercaseAsciiValueVector); + + // Flagged (true) if any non-zero + if (Vector.GreaterThanAny( + // Non-alphanumeric (i.e. outside of alphanumeric range) + Vector.BitwiseAnd( + // Outside range 0-9 + Vector.BitwiseOr( + Vector.LessThan(vector, ZeroDigitValueVector), + Vector.GreaterThan(vector, NineDigitValueVector)), + // Outside range [a-zA-Z] + Vector.BitwiseOr( + Vector.LessThan(lowercaseVector, LowercaseAValueVector), + Vector.GreaterThan(lowercaseVector, LowercaseZValueVector))), + AsciiNullValueVector)) + { + return true; + } + } + } + + for (var i = text.Length - remainder; i < text.Length; i++) + { + uint chr = text[i]; + + if (CharIsOutsideRange(chr, '0', '9') & // Not 0-9 + CharIsOutsideRange(chr | 0b100000U, 'a', 'z')) // Not A-Z in any casing (setting the 6th bit changes uppercase into lowercase) + { + return true; + } + } + + return false; + } + + /// + /// + /// This method detects non-word characters. + /// + /// + /// It returns true, unless the given consists exclusively of [0-9A-Za-z_], i.e. ASCII letters/digits/underscores. + /// + /// + public static bool ContainsNonWordCharacters(ReadOnlySpan text) + { + var remainder = text.Length; + + // Attempt to process most of the input with SIMD + if (Vector.IsHardwareAccelerated) + { + remainder %= Vector.Count; + + var vectors = MemoryMarshal.Cast>(text[..^remainder]); + + foreach (var vector in vectors) + { + var lowercaseVector = Vector.BitwiseOr(vector, ToLowercaseAsciiValueVector); + + // Flagged (true) if any non-zero + if (Vector.GreaterThanAny( + // Xor results in zero (not flagged) for underscores (non-alphanumeric=1, underscore=1) and alphanumerics (non-alphanumeric=0, underscore=0) + // Xor results in one (flagged) otherwise + Vector.Xor( + // Non-alphanumeric (i.e. outside of alphanumeric range) + Vector.BitwiseAnd( + // Outside range 0-9 + Vector.BitwiseOr( + Vector.LessThan(vector, ZeroDigitValueVector), + Vector.GreaterThan(vector, NineDigitValueVector)), + // Outside range [a-zA-Z] + Vector.BitwiseOr( + Vector.LessThan(lowercaseVector, LowercaseAValueVector), + Vector.GreaterThan(lowercaseVector, LowercaseZValueVector))), + // An underscore + Vector.Equals(vector, UnderscoreValueVector)), + AsciiNullValueVector)) + { + return true; + } + } + } + + for (var i = text.Length - remainder; i < text.Length; i++) + { + uint chr = text[i]; + + if (CharIsOutsideRange(chr, '0', '9') & // Not 0-9 + CharIsOutsideRange(chr | 0b100000U, 'a', 'z') & // Not A-Z in any casing (setting the 6th bit changes uppercase into lowercase) + chr != '_') // Not the underscore + { + return true; + } + } + + return false; + } + + /// + /// + /// This method detects non-ASCII characters. + /// + /// + /// It returns true, unless the given consists exclusively of ASCII characters. + /// + /// + public static bool ContainsNonAsciiCharacters(ReadOnlySpan text) + { + var remainder = text.Length; + + // Attempt to process most of the input with SIMD + if (Vector.IsHardwareAccelerated) + { + remainder %= Vector.Count; + + var vectors = MemoryMarshal.Cast>(text[..^remainder]); + + foreach (var vector in vectors) + if (Vector.GreaterThanAny(vector, MaxAsciiValueVector)) + return true; + } + + // Process the remainder char-by-char + const uint maxAsciiChar = (uint)SByte.MaxValue; + foreach (var chr in text[^remainder..]) + if (chr > maxAsciiChar) + return true; + + return false; + } + + /// + /// + /// This method detects non-printable characters and non-ASCII characters. + /// + /// + /// It returns true, unless the given consists exclusively of printable ASCII characters. + /// + /// + /// Pass true (default) to flag \r, \n, and \t as non-printable characters. Pass false to overlook them. + public static bool ContainsNonAsciiOrNonPrintableCharacters(ReadOnlySpan text, bool flagNewLinesAndTabs = true) + { + // ASCII chars below ' ' (32) are control characters + // ASCII char SByte.MaxValue (127) is a control character + // Characters above SByte.MaxValue (127) are non-ASCII + + if (!flagNewLinesAndTabs) + return EvaluateOverlookingNewLinesAndTabs(text); + + var remainder = text.Length; + + // Attempt to process most of the input with SIMD + if (Vector.IsHardwareAccelerated) + { + remainder %= Vector.Count; + + var vectors = MemoryMarshal.Cast>(text[..^remainder]); + + foreach (var vector in vectors) + if (Vector.LessThanAny(vector, SpaceValueVector) | Vector.GreaterThanOrEqualAny(vector, MaxAsciiValueVector)) + return true; + } + + // Process the remainder char-by-char + const uint minChar = ' '; + const uint maxChar = (uint)SByte.MaxValue - 1U; + foreach (var chr in text[^remainder..]) + if (CharIsOutsideRange(chr, minChar, maxChar)) + return true; + + return false; + + // Local function that performs the work while overlooking \r, \n, and \t characters + static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) + { + var remainder = text.Length; + + // Attempt to process most of the input with SIMD + if (Vector.IsHardwareAccelerated) + { + remainder %= Vector.Count; + + var vectors = MemoryMarshal.Cast>(text[..^remainder]); + + foreach (var vector in vectors) + { + // If the vector contains any non-ASCII or non-printable characters + if (Vector.LessThanAny(vector, SpaceValueVector) | Vector.GreaterThanOrEqualAny(vector, MaxAsciiValueVector)) // Usually false, so short-circuit + { + for (var i = 0; i < Vector.Count; i++) + { + uint chr = vector[i]; + + if (CharIsOutsideRange(chr, minChar, maxChar) && // Usually false, so short-circuit + (CharIsOutsideRange(chr, '\t', '\n') & chr != '\r')) + return true; + } + } + } + } + + // Process the remainder char-by-char + for (var i = text.Length - remainder; i < text.Length; i++) + { + uint chr = text[i]; + + if (CharIsOutsideRange(chr, minChar, maxChar) && // Usually false, so short-circuit + (CharIsOutsideRange(chr, '\t', '\n') & chr != '\r')) + return true; + } + + return false; + } + } + + /// + /// + /// This method detects non-printable characters, whitespace characters, and non-ASCII characters. + /// + /// + /// It returns true, unless the given consists exclusively of printable ASCII characters that are not whitespace. + /// + /// + public static bool ContainsNonAsciiOrNonPrintableOrWhitespaceCharacters(ReadOnlySpan text) + { + // Characters above SByte.MaxValue (127) are non-ASCII + // ASCII char SByte.MaxValue (127) is a control character + // ASCII chars ' ' (32) and below are all the other control chars and all whitespace chars + + var remainder = text.Length; + + // Attempt to process most of the input with SIMD + if (Vector.IsHardwareAccelerated) + { + remainder %= Vector.Count; + + var vectors = MemoryMarshal.Cast>(text[..^remainder]); + + foreach (var vector in vectors) + if (Vector.LessThanOrEqualAny(vector, SpaceValueVector) | Vector.GreaterThanOrEqualAny(vector, MaxAsciiValueVector)) + return true; + } + + // Process the remainder char-by-char + const uint minChar = ' ' + 1U; + const uint maxChar = (uint)SByte.MaxValue - 1U; + foreach (var chr in text[^remainder..]) + if (CharIsOutsideRange(chr, minChar, maxChar)) + return true; + + return false; + } + + /// + /// + /// This method detects non-printable characters, such as control characters. + /// It does not detect whitespace characters, even if they are zero-width. + /// + /// + /// It returns true, unless the given consists exclusively of printable characters. + /// + /// + /// + /// + /// A parameter controls whether this method flags newline and tab characters, allowing single-line vs. multiline input to be validated. + /// + /// + /// Pass true to flag \r, \n, and \t as non-printable characters. Pass false to overlook them. + public static bool ContainsNonPrintableCharacters(ReadOnlySpan text, bool flagNewLinesAndTabs) + { + return flagNewLinesAndTabs + ? EvaluateIncludingNewLinesAndTabs(text) + : EvaluateOverlookingNewLinesAndTabs(text); + + // Local function that performs the work while including \r, \n, and \t characters + static bool EvaluateIncludingNewLinesAndTabs(ReadOnlySpan text) + { + foreach (var chr in text) + { + var category = Char.GetUnicodeCategory(chr); + + if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned) + return true; + } + + return false; + } + + // Local function that performs the work while overlooking \r, \n, and \t characters + static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) + { + foreach (var chr in text) + { + var category = Char.GetUnicodeCategory(chr); + + if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned) + { + if (chr == '\r' | chr == '\n' | chr == '\t') continue; // Exempt + return true; + } + } + + return false; + } + } + + /// + /// + /// This method detects double quotes (") and non-printable characters, such as control characters. + /// It does not detect whitespace characters, even if they are zero-width. + /// + /// + /// It returns true, unless the given consists exclusively of printable characters that are not double quotes ("). + /// + /// + /// + /// + /// A parameter controls whether this method flags newline and tab characters, allowing single-line vs. multiline input to be validated. + /// + /// + /// Pass true to flag \r, \n, and \t as non-printable characters. Pass false to overlook them. + public static bool ContainsNonPrintableCharactersOrDoubleQuotes(ReadOnlySpan text, bool flagNewLinesAndTabs) + { + return flagNewLinesAndTabs + ? EvaluateIncludingNewLinesAndTabs(text) + : EvaluateOverlookingNewLinesAndTabs(text); + + // Local function that performs the work while including \r, \n, and \t characters + static bool EvaluateIncludingNewLinesAndTabs(ReadOnlySpan text) + { + foreach (var chr in text) + { + var category = Char.GetUnicodeCategory(chr); + + if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned | chr == '"') + return true; + } + + return false; + } + + // Local function that performs the work while overlooking \r, \n, and \t characters + static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) + { + foreach (var chr in text) + { + var category = Char.GetUnicodeCategory(chr); + + if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned | chr == '"') + { + if (chr == '\r' | chr == '\n' | chr == '\t') continue; // Exempt + return true; + } + } + + return false; + } + } + + /// + /// + /// This method detects whitespace characters and non-printable characters. + /// + /// + /// It returns true, unless the given consists exclusively of printable characters that are not whitespace. + /// + /// + public static bool ContainsWhitespaceOrNonPrintableCharacters(ReadOnlySpan text) + { + // https://referencesource.microsoft.com/#mscorlib/system/globalization/charunicodeinfo.cs,9c0ae0026fafada0 + // 11=SpaceSeparator + // 12=LineSeparator + // 13=ParagraphSeparator + // 14=Control + const uint minValue = (uint)UnicodeCategory.SpaceSeparator; + const uint maxValue = (uint)UnicodeCategory.Control; + + foreach (var chr in text) + { + var category = Char.GetUnicodeCategory(chr); + + if (ValueIsInRange((uint)category, minValue, maxValue) | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned) + return true; + } + + return false; + } + + /// + /// + /// Returns whether the given character is outside of the given range of values. + /// Values equal to the minimum or maximum are considered to be inside the range. + /// + /// + /// This method uses only a single comparison. + /// + /// + /// The character to compare. + /// The minimum value considered inside the range. + /// The maximum value considered inside the range. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool CharIsOutsideRange(uint chr, uint minValue, uint maxValue) + { + // The implementation is optimized to minimize the number of comparisons + // By using uints, a negative value becomes a very large value + // Then, by subtracting the range's min char (e.g. 'a'), only chars INSIDE the range have values 0 through (max-min), e.g. 0 through 25 (for 'a' through 'z') + // To then check if the value is outside of the range, we can simply check if it is greater + // See also https://referencesource.microsoft.com/#mscorlib/system/string.cs,289 + + return chr - minValue > (maxValue - minValue); + } + + /// + /// + /// Returns whether the given value is inside of the given range of values. + /// Values equal to the minimum or maximum are considered to be inside the range. + /// + /// + /// This method uses only a single comparison. + /// + /// + /// The value to compare. + /// The minimum value considered inside the range. + /// The maximum value considered inside the range. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ValueIsInRange(uint value, uint minValue, uint maxValue) + { + // The implementation is optimized to minimize the number of comparisons + // By using uints, a negative value becomes a very large value + // Then, by subtracting the range's min char (e.g. 'a'), only chars INSIDE the range have values 0 through (max-min), e.g. 0 through 25 (for 'a' through 'z') + // To then check if the value is outside of the range, we can simply check if it is greater + // See also https://referencesource.microsoft.com/#mscorlib/system/string.cs,289 + + return value - minValue <= (maxValue - minValue); + } +} diff --git a/DomainModeling/Conversions/ValueWrapperUnwrapper.cs b/DomainModeling/Conversions/ValueWrapperUnwrapper.cs index 190a9d6..d29ebf5 100644 --- a/DomainModeling/Conversions/ValueWrapperUnwrapper.cs +++ b/DomainModeling/Conversions/ValueWrapperUnwrapper.cs @@ -15,17 +15,28 @@ public static class ValueWrapperUnwrapper { #region Wrap + /// + /// Wraps a in a . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TWrapper Wrap(TValue value) + where TWrapper : IValueWrapper + { + return TWrapper.Create(value); + } + /// /// Wraps a in a . /// [MethodImpl(MethodImplOptions.AggressiveInlining)] [return: NotNullIfNotNull(nameof(value))] public static TWrapper? Wrap(TValue? value) - where TWrapper : IValueWrapper + where TWrapper : struct, IValueWrapper + where TValue : struct { - return value is null - ? default - : TWrapper.Create(value); + return value is { } actual + ? TWrapper.Create(actual) + : default; } #endregion @@ -36,14 +47,23 @@ public static class ValueWrapperUnwrapper /// Unwraps the from a . /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static TValue? Unwrap( - TWrapper? instance) + public static TValue? Unwrap(TWrapper instance) where TWrapper : IValueWrapper { - return instance is null - ? default - : instance.Value; + return instance.Value; } + /// + /// Unwraps the from a . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TValue? Unwrap(TWrapper? instance) + where TWrapper : struct, IValueWrapper + { + return instance is { } actual + ? actual.Value + : default; + } + #endregion } diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index bdb1f71..7ed323f 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -35,24 +35,30 @@ Release notes: 4.0.0: Platform support: -- BREAKING: Platform support: Dropped support for .NET 6.0 and .NET 7.0 (EOL). +- BREAKING: Dropped support for .NET 6.0 and .NET 7.0 (EOL). + +The base class is dead - long live the interface! +- Feature: Generated Wrappers can now be structs. Unvalidated instances are avoided via analyzer warning against `default`. +- Feature: Generated [Wrapper]ValueObjects can now be records or use custom base. +- BREAKING: [Wrapper]ValueObject generator replaced base class by interface. StringComparison property may need to drop "override" keyword and become private. +- BREAKING: Lack of ValueObject base class requires the string validation methods to be accessed via ValueObjectStringValidator class. Completed support for nested wrappers: -- Feature: Nested WrapperValueObject/Identity types can now also wrap/unwrap and serialize/deserialize directly to/from their core (deepest) underlying type. -- Feature: EF conversions for such types now automatically map to and from the core type. -- Feature: A deeper underlying type can be simulated, e.g. LazyStringId : IIdentity<Lazy<string>>, ICoreValueWrapper<LazyStringId, string>. -- BREAKING: ISerializableDomainObject is deprecated in favor of IValueWrapper, a clear and comprehensive type to represented generic value wrappers. -- BREAKING: IIdentityConfigurator and IWrapperValueObjectConfigurator now receive an additional type parameter on their methods, namely the core type. - -Correct string comparisons with EF: -- Feature: ConfigureIdentityConventions()/ConfigureWrapperValueObjectConventions() now set a PROVIDER value comparer for each string wrapper property, matching the type's case-sensitivity. Since EF Core 7, EF compares keys using the provider type instead of the model type. -- Feature: ConfigureIdentityConventions()/ConfigureWrapperValueObjectConventions() now warn if a string wrapper property has a collation mismatching the type's case-sensitivity, unless collation was explicitly chosen. -- Feature: ConfigureIdentityConventions()/ConfigureWrapperValueObjectConventions() now take an optional "options" parameter, which allows specifying the respective collations for case-sensitive vs. ignore-case string wrappers. -- Feature: ConfigureDomainModelConventions() now has convenience extension methods CustomizeIdentityConventions()/CustomizeWrapperValueObjectConventions(), for easy custom conventions, such as based on the core underlying type. +- Feature: Nested Wrappers/Identities can now also convert, wrap/unwrap, and serialize/deserialize directly to/from their core (deepest) underlying type. +- Feature: EF conversions for these now automatically map to/from the core type. +- Feature: A different underlying type can be simulated, e.g. LongId : IIdentity<int>, ICoreValueWrapper<LongId, long>. +- BREAKING: ISerializableDomainObject deprecated in favor of IValueWrapper, which represents any single-value wrapper. +- BREAKING: IIdentityConfigurator/IWrapperValueObjectConfigurator now receive additional method type parameter TCore. + +Correct string comparisons with EF via ConfigureIdentityConventions()/ConfigureWrapperValueObjectConventions(): +- Feature: These now set a PROVIDER value comparer for each string wrapper property, matching type's case-sensitivity. Since EF Core 7, EF compares keys using provider type instead of model type. +- Feature: These now warn if a string wrapper property has a collation mismatching the type's case-sensitivity (unless collation is explicit). +- Feature: These now take optional "options" parameter, which allows specifying collations for case-sensitive vs. ignore-case string wrappers. +- Feature: ConfigureDomainModelConventions() now has convenience extension methods CustomizeIdentityConventions()/CustomizeWrapperValueObjectConventions(), for easy custom conventions, such as based on core underlying type. Performance: -- Enhancement: Reduced assembly size by having source-generated WrapperValueObject/Identity types use generic JSON serializers instead of generating their own. -- Enhancement: Reduced assembly size by moving the type-inference Equals() and Compare() helpers on generated ValueObjects into a helper class. +- Enhancement: Reduced assembly size by using generic JSON serializers instead of generated ones. +- Enhancement: Reduced assembly size by moving type-inferred Equals() and Compare() helpers from generated ValueObjects into helper class. - Enhancement: Improved source generator performance. Misc: @@ -106,4 +112,15 @@ Misc: + + + + + + + + + + + diff --git a/DomainModeling/ValueObject.ValidationHelpers.cs b/DomainModeling/ValueObject.ValidationHelpers.cs index 51acaf5..11b5d1a 100644 --- a/DomainModeling/ValueObject.ValidationHelpers.cs +++ b/DomainModeling/ValueObject.ValidationHelpers.cs @@ -1,51 +1,11 @@ -using System.Globalization; -using System.Numerics; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using Architect.DomainModeling.Comparisons; namespace Architect.DomainModeling; +// For backward compatibility, these methods still exist on ValueObject + public abstract partial class ValueObject { - // Note: Most methods in this class expect to reach their final return statement, so they optimize for that case with logical instead of conditional operators, to reduce branching - - /// - /// A vector filled completely with the ASCII null character's value (0). - /// - private static readonly Vector AsciiNullValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)0U, Vector.Count).ToArray())[0]; - /// - /// A vector filled completely with the ' ' (space) character's value (32). - /// - private static readonly Vector SpaceValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)' ', Vector.Count).ToArray())[0]; - /// - /// A vector filled completely with the ASCII zero digit character's value (48). - /// - private static readonly Vector ZeroDigitValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)'0', Vector.Count).ToArray())[0]; - /// - /// A vector filled completely with the ASCII nine digit character's value (57). - /// - private static readonly Vector NineDigitValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)'9', Vector.Count).ToArray())[0]; - /// - /// A vector filled completely with the '_' character's value (95). - /// - private static readonly Vector UnderscoreValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)'_', Vector.Count).ToArray())[0]; - /// - /// A vector filled completely with the 'a' character's value (97). - /// - private static readonly Vector LowercaseAValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)'a', Vector.Count).ToArray())[0]; - /// - /// A vector filled completely with the 'z' character's value (122). - /// - private static readonly Vector LowercaseZValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)'z', Vector.Count).ToArray())[0]; - /// - /// A vector filled completely with the greatest ASCII character's value (127). - /// - private static readonly Vector MaxAsciiValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)SByte.MaxValue, Vector.Count).ToArray())[0]; - /// - /// A vector filled completely with a character that, when binary OR'ed with an ASCII letter, results in the corresponding lowercase letter. - /// - private static readonly Vector ToLowercaseAsciiValueVector = MemoryMarshal.Cast>(Enumerable.Repeat((ushort)0b100000U, Vector.Count).ToArray())[0]; - /// /// /// This method detects non-alphanumeric characters. @@ -56,50 +16,7 @@ public abstract partial class ValueObject /// protected static bool ContainsNonAlphanumericCharacters(ReadOnlySpan text) { - var remainder = text.Length; - - // Attempt to process most of the input with SIMD - if (Vector.IsHardwareAccelerated) - { - remainder %= Vector.Count; - - var vectors = MemoryMarshal.Cast>(text[..^remainder]); - - foreach (var vector in vectors) - { - var lowercaseVector = Vector.BitwiseOr(vector, ToLowercaseAsciiValueVector); - - // Flagged (true) if any non-zero - if (Vector.GreaterThanAny( - // Non-alphanumeric (i.e. outside of alphanumeric range) - Vector.BitwiseAnd( - // Outside range 0-9 - Vector.BitwiseOr( - Vector.LessThan(vector, ZeroDigitValueVector), - Vector.GreaterThan(vector, NineDigitValueVector)), - // Outside range [a-zA-Z] - Vector.BitwiseOr( - Vector.LessThan(lowercaseVector, LowercaseAValueVector), - Vector.GreaterThan(lowercaseVector, LowercaseZValueVector))), - AsciiNullValueVector)) - { - return true; - } - } - } - - for (var i = text.Length - remainder; i < text.Length; i++) - { - uint chr = text[i]; - - if (CharIsOutsideRange(chr, '0', '9') & // Not 0-9 - CharIsOutsideRange(chr | 0b100000U, 'a', 'z')) // Not A-Z in any casing (setting the 6th bit changes uppercase into lowercase) - { - return true; - } - } - - return false; + return ValueObjectStringValidator.ContainsNonAlphanumericCharacters(text); } /// @@ -112,56 +29,7 @@ protected static bool ContainsNonAlphanumericCharacters(ReadOnlySpan text) /// protected static bool ContainsNonWordCharacters(ReadOnlySpan text) { - var remainder = text.Length; - - // Attempt to process most of the input with SIMD - if (Vector.IsHardwareAccelerated) - { - remainder %= Vector.Count; - - var vectors = MemoryMarshal.Cast>(text[..^remainder]); - - foreach (var vector in vectors) - { - var lowercaseVector = Vector.BitwiseOr(vector, ToLowercaseAsciiValueVector); - - // Flagged (true) if any non-zero - if (Vector.GreaterThanAny( - // Xor results in zero (not flagged) for underscores (non-alphanumeric=1, underscore=1) and alphanumerics (non-alphanumeric=0, underscore=0) - // Xor results in one (flagged) otherwise - Vector.Xor( - // Non-alphanumeric (i.e. outside of alphanumeric range) - Vector.BitwiseAnd( - // Outside range 0-9 - Vector.BitwiseOr( - Vector.LessThan(vector, ZeroDigitValueVector), - Vector.GreaterThan(vector, NineDigitValueVector)), - // Outside range [a-zA-Z] - Vector.BitwiseOr( - Vector.LessThan(lowercaseVector, LowercaseAValueVector), - Vector.GreaterThan(lowercaseVector, LowercaseZValueVector))), - // An underscore - Vector.Equals(vector, UnderscoreValueVector)), - AsciiNullValueVector)) - { - return true; - } - } - } - - for (var i = text.Length - remainder; i < text.Length; i++) - { - uint chr = text[i]; - - if (CharIsOutsideRange(chr, '0', '9') & // Not 0-9 - CharIsOutsideRange(chr | 0b100000U, 'a', 'z') & // Not A-Z in any casing (setting the 6th bit changes uppercase into lowercase) - chr != '_') // Not the underscore - { - return true; - } - } - - return false; + return ValueObjectStringValidator.ContainsNonWordCharacters(text); } /// @@ -174,27 +42,7 @@ protected static bool ContainsNonWordCharacters(ReadOnlySpan text) /// protected static bool ContainsNonAsciiCharacters(ReadOnlySpan text) { - var remainder = text.Length; - - // Attempt to process most of the input with SIMD - if (Vector.IsHardwareAccelerated) - { - remainder %= Vector.Count; - - var vectors = MemoryMarshal.Cast>(text[..^remainder]); - - foreach (var vector in vectors) - if (Vector.GreaterThanAny(vector, MaxAsciiValueVector)) - return true; - } - - // Process the remainder char-by-char - const uint maxAsciiChar = (uint)SByte.MaxValue; - foreach (var chr in text[^remainder..]) - if (chr > maxAsciiChar) - return true; - - return false; + return ValueObjectStringValidator.ContainsNonAsciiCharacters(text); } /// @@ -208,77 +56,7 @@ protected static bool ContainsNonAsciiCharacters(ReadOnlySpan text) /// Pass true (default) to flag \r, \n, and \t as non-printable characters. Pass false to overlook them. protected static bool ContainsNonAsciiOrNonPrintableCharacters(ReadOnlySpan text, bool flagNewLinesAndTabs = true) { - // ASCII chars below ' ' (32) are control characters - // ASCII char SByte.MaxValue (127) is a control character - // Characters above SByte.MaxValue (127) are non-ASCII - - if (!flagNewLinesAndTabs) - return EvaluateOverlookingNewLinesAndTabs(text); - - var remainder = text.Length; - - // Attempt to process most of the input with SIMD - if (Vector.IsHardwareAccelerated) - { - remainder %= Vector.Count; - - var vectors = MemoryMarshal.Cast>(text[..^remainder]); - - foreach (var vector in vectors) - if (Vector.LessThanAny(vector, SpaceValueVector) | Vector.GreaterThanOrEqualAny(vector, MaxAsciiValueVector)) - return true; - } - - // Process the remainder char-by-char - const uint minChar = ' '; - const uint maxChar = (uint)SByte.MaxValue - 1U; - foreach (var chr in text[^remainder..]) - if (CharIsOutsideRange(chr, minChar, maxChar)) - return true; - - return false; - - // Local function that performs the work while overlooking \r, \n, and \t characters - static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) - { - var remainder = text.Length; - - // Attempt to process most of the input with SIMD - if (Vector.IsHardwareAccelerated) - { - remainder %= Vector.Count; - - var vectors = MemoryMarshal.Cast>(text[..^remainder]); - - foreach (var vector in vectors) - { - // If the vector contains any non-ASCII or non-printable characters - if (Vector.LessThanAny(vector, SpaceValueVector) | Vector.GreaterThanOrEqualAny(vector, MaxAsciiValueVector)) // Usually false, so short-circuit - { - for (var i = 0; i < Vector.Count; i++) - { - uint chr = vector[i]; - - if (CharIsOutsideRange(chr, minChar, maxChar) && // Usually false, so short-circuit - (CharIsOutsideRange(chr, '\t', '\n') & chr != '\r')) - return true; - } - } - } - } - - // Process the remainder char-by-char - for (var i = text.Length - remainder; i < text.Length; i++) - { - uint chr = text[i]; - - if (CharIsOutsideRange(chr, minChar, maxChar) && // Usually false, so short-circuit - (CharIsOutsideRange(chr, '\t', '\n') & chr != '\r')) - return true; - } - - return false; - } + return ValueObjectStringValidator.ContainsNonAsciiOrNonPrintableCharacters(text, flagNewLinesAndTabs); } /// @@ -291,32 +69,7 @@ static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) /// protected static bool ContainsNonAsciiOrNonPrintableOrWhitespaceCharacters(ReadOnlySpan text) { - // Characters above SByte.MaxValue (127) are non-ASCII - // ASCII char SByte.MaxValue (127) is a control character - // ASCII chars ' ' (32) and below are all the other control chars and all whitespace chars - - var remainder = text.Length; - - // Attempt to process most of the input with SIMD - if (Vector.IsHardwareAccelerated) - { - remainder %= Vector.Count; - - var vectors = MemoryMarshal.Cast>(text[..^remainder]); - - foreach (var vector in vectors) - if (Vector.LessThanOrEqualAny(vector, SpaceValueVector) | Vector.GreaterThanOrEqualAny(vector, MaxAsciiValueVector)) - return true; - } - - // Process the remainder char-by-char - const uint minChar = ' ' + 1U; - const uint maxChar = (uint)SByte.MaxValue - 1U; - foreach (var chr in text[^remainder..]) - if (CharIsOutsideRange(chr, minChar, maxChar)) - return true; - - return false; + return ValueObjectStringValidator.ContainsNonAsciiOrNonPrintableOrWhitespaceCharacters(text); } /// @@ -336,40 +89,7 @@ protected static bool ContainsNonAsciiOrNonPrintableOrWhitespaceCharacters(ReadO /// Pass true to flag \r, \n, and \t as non-printable characters. Pass false to overlook them. protected static bool ContainsNonPrintableCharacters(ReadOnlySpan text, bool flagNewLinesAndTabs) { - return flagNewLinesAndTabs - ? EvaluateIncludingNewLinesAndTabs(text) - : EvaluateOverlookingNewLinesAndTabs(text); - - // Local function that performs the work while including \r, \n, and \t characters - static bool EvaluateIncludingNewLinesAndTabs(ReadOnlySpan text) - { - foreach (var chr in text) - { - var category = Char.GetUnicodeCategory(chr); - - if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned) - return true; - } - - return false; - } - - // Local function that performs the work while overlooking \r, \n, and \t characters - static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) - { - foreach (var chr in text) - { - var category = Char.GetUnicodeCategory(chr); - - if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned) - { - if (chr == '\r' | chr == '\n' | chr == '\t') continue; // Exempt - return true; - } - } - - return false; - } + return ValueObjectStringValidator.ContainsNonPrintableCharacters(text, flagNewLinesAndTabs); } /// @@ -389,40 +109,7 @@ static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) /// Pass true to flag \r, \n, and \t as non-printable characters. Pass false to overlook them. protected static bool ContainsNonPrintableCharactersOrDoubleQuotes(ReadOnlySpan text, bool flagNewLinesAndTabs) { - return flagNewLinesAndTabs - ? EvaluateIncludingNewLinesAndTabs(text) - : EvaluateOverlookingNewLinesAndTabs(text); - - // Local function that performs the work while including \r, \n, and \t characters - static bool EvaluateIncludingNewLinesAndTabs(ReadOnlySpan text) - { - foreach (var chr in text) - { - var category = Char.GetUnicodeCategory(chr); - - if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned | chr == '"') - return true; - } - - return false; - } - - // Local function that performs the work while overlooking \r, \n, and \t characters - static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) - { - foreach (var chr in text) - { - var category = Char.GetUnicodeCategory(chr); - - if (category == UnicodeCategory.Control | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned | chr == '"') - { - if (chr == '\r' | chr == '\n' | chr == '\t') continue; // Exempt - return true; - } - } - - return false; - } + return ValueObjectStringValidator.ContainsNonPrintableCharactersOrDoubleQuotes(text, flagNewLinesAndTabs); } /// @@ -435,70 +122,6 @@ static bool EvaluateOverlookingNewLinesAndTabs(ReadOnlySpan text) /// protected static bool ContainsWhitespaceOrNonPrintableCharacters(ReadOnlySpan text) { - // https://referencesource.microsoft.com/#mscorlib/system/globalization/charunicodeinfo.cs,9c0ae0026fafada0 - // 11=SpaceSeparator - // 12=LineSeparator - // 13=ParagraphSeparator - // 14=Control - const uint minValue = (uint)UnicodeCategory.SpaceSeparator; - const uint maxValue = (uint)UnicodeCategory.Control; - - foreach (var chr in text) - { - var category = Char.GetUnicodeCategory(chr); - - if (ValueIsInRange((uint)category, minValue, maxValue) | category == UnicodeCategory.PrivateUse | category == UnicodeCategory.OtherNotAssigned) - return true; - } - - return false; - } - - /// - /// - /// Returns whether the given character is outside of the given range of values. - /// Values equal to the minimum or maximum are considered to be inside the range. - /// - /// - /// This method uses only a single comparison. - /// - /// - /// The character to compare. - /// The minimum value considered inside the range. - /// The maximum value considered inside the range. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool CharIsOutsideRange(uint chr, uint minValue, uint maxValue) - { - // The implementation is optimized to minimize the number of comparisons - // By using uints, a negative value becomes a very large value - // Then, by subtracting the range's min char (e.g. 'a'), only chars INSIDE the range have values 0 through (max-min), e.g. 0 through 25 (for 'a' through 'z') - // To then check if the value is outside of the range, we can simply check if it is greater - // See also https://referencesource.microsoft.com/#mscorlib/system/string.cs,289 - - return chr - minValue > (maxValue - minValue); - } - - /// - /// - /// Returns whether the given value is inside of the given range of values. - /// Values equal to the minimum or maximum are considered to be inside the range. - /// - /// - /// This method uses only a single comparison. - /// - /// - /// The value to compare. - /// The minimum value considered inside the range. - /// The maximum value considered inside the range. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool ValueIsInRange(uint value, uint minValue, uint maxValue) - { - // The implementation is optimized to minimize the number of comparisons - // By using uints, a negative value becomes a very large value - // Then, by subtracting the range's min char (e.g. 'a'), only chars INSIDE the range have values 0 through (max-min), e.g. 0 through 25 (for 'a' through 'z') - // To then check if the value is outside of the range, we can simply check if it is greater - // See also https://referencesource.microsoft.com/#mscorlib/system/string.cs,289 - - return value - minValue <= (maxValue - minValue); + return ValueObjectStringValidator.ContainsWhitespaceOrNonPrintableCharacters(text); } } diff --git a/DomainModeling/ValueObjectExtensions.cs b/DomainModeling/ValueObjectExtensions.cs new file mode 100644 index 0000000..d4d5558 --- /dev/null +++ b/DomainModeling/ValueObjectExtensions.cs @@ -0,0 +1,19 @@ +using System.Runtime.CompilerServices; + +namespace Architect.DomainModeling; + +/// +/// Provides extension methods related to types. +/// +public static class ValueObjectExtensions +{ + /// + /// Returns whether the current is equal to the type's default value, according to its own . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsDefault(this TValueObject instance) + where TValueObject : struct, IValueObject, IEquatable + { + return instance.Equals(default); + } +} From a3b4a861e391011fcb2800744743e63c0563a167 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 17 Sep 2025 21:47:46 +0200 Subject: [PATCH 17/23] Nicer DebuggerDisplay of Wrappers/Identities. --- DomainModeling.Generator/IdentityGenerator.cs | 2 ++ DomainModeling.Generator/WrapperValueObjectGenerator.cs | 2 ++ DomainModeling/DomainModeling.csproj | 1 + 3 files changed, 5 insertions(+) diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index 5880eb7..9cb8ffd 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -509,6 +509,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var source = $@" using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Architect.DomainModeling; @@ -525,6 +526,7 @@ namespace {containingNamespace} {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} {(hasIdentityValueObjectAttribute ? "" : $"[IdentityValueObject<{underlyingTypeFullyQualifiedName}>]")} + [DebuggerDisplay(""{{ToString(){(coreTypeFullyQualifiedName == "string" ? "" : ",nq")}}}"")] [CompilerGenerated] {accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} : IIdentity<{underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index af5d2c4..db2bd5f 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -437,6 +437,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var source = $@" using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Architect.DomainModeling; @@ -450,6 +451,7 @@ namespace {containingNamespace} {{ {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(typeName, underlyingTypeFullyQualifiedName)} + [DebuggerDisplay(""{{ToString(){(coreTypeFullyQualifiedName == "string" ? "" : ",nq")}}}"")] [CompilerGenerated] {generatable.Accessibility.ToCodeString()} {(generatable.IsClass ? "sealed" : "readonly")} partial {(generatable.IsRecord ? "record " : "")}{(generatable.IsClass ? "class" : "struct")} {typeName} : IWrapperValueObject<{underlyingTypeFullyQualifiedName}>, IEquatable<{typeName}>, diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 7ed323f..d136471 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -71,6 +71,7 @@ Misc: - Fix: Fixed bug where DummyBuilder generator struggled with nested types. - Fix: Fixed bug where "no source generation on nested type" warning would not show. - Enhancement: Generated types now have the CompilerGeneratedAttribute. +- Enhancement: Nicer DebuggerDisplay of Wrappers/Identities. - Enhancement: Generated struct Wrappers/Identities now generate NULLABLE comparison operators, to circumvent counterintuitive lifting behavior. - Enhancement: Improved clarity of analyzer warnings, by stopping before subsequent problems occur. - Enhancement: Improved correctness of trimming. From a68f1c6208f67ef31f23e65d0649c6608015e5d6 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 17 Sep 2025 21:49:45 +0200 Subject: [PATCH 18/23] Made Entity.Id private init. --- DomainModeling/DomainModeling.csproj | 1 + DomainModeling/Entity.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index d136471..c875436 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -71,6 +71,7 @@ Misc: - Fix: Fixed bug where DummyBuilder generator struggled with nested types. - Fix: Fixed bug where "no source generation on nested type" warning would not show. - Enhancement: Generated types now have the CompilerGeneratedAttribute. +- Enhancement: Entity.Id has private init. - Enhancement: Nicer DebuggerDisplay of Wrappers/Identities. - Enhancement: Generated struct Wrappers/Identities now generate NULLABLE comparison operators, to circumvent counterintuitive lifting behavior. - Enhancement: Improved clarity of analyzer warnings, by stopping before subsequent problems occur. diff --git a/DomainModeling/Entity.cs b/DomainModeling/Entity.cs index 143617a..8b0640f 100644 --- a/DomainModeling/Entity.cs +++ b/DomainModeling/Entity.cs @@ -81,7 +81,7 @@ public abstract class Entity< /// /// The entity's unique identity. /// - public TId Id { get; } + public TId Id { get; private init; } /// The unique identity for the entity. protected Entity(TId id) From a6c0392e4c36c293df9ad10ea48cab1598f7a3f6 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:40:07 +0200 Subject: [PATCH 19/23] DummyBuilder records clone themselves on each step, for reuse. --- .../DummyBuilderGenerator.cs | 7 +++--- DomainModeling.Tests/DummyBuilderTests.cs | 25 ++++++++++++++++++- DomainModeling/DomainModeling.csproj | 1 + README.md | 8 +++--- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/DomainModeling.Generator/DummyBuilderGenerator.cs b/DomainModeling.Generator/DummyBuilderGenerator.cs index 93e4e90..74d19cc 100644 --- a/DomainModeling.Generator/DummyBuilderGenerator.cs +++ b/DomainModeling.Generator/DummyBuilderGenerator.cs @@ -297,14 +297,15 @@ namespace {containingNamespace} /// That way, if the constructor changes, only the builder needs to be adjusted, rather than lots of test methods. /// /// - [CompilerGenerated] {type.DeclaredAccessibility.ToCodeString()} partial{(builder.IsRecord ? " record" : "")} class {typeName} + [CompilerGenerated] {type.DeclaredAccessibility.ToCodeString()} partial {(builder.IsRecord ? "record " : "")}class {typeName} {{ {joinedComponents} private {typeName} With(Action<{typeName}> assignment) {{ - assignment(this); - return this; + var instance = this{(builder.IsRecord ? " with { }" : "")}; // If the type is a record, a copy is made, to enable reuse per step + assignment(instance); + return instance; }} {(hasBuildMethod ? "/*" : "")} diff --git a/DomainModeling.Tests/DummyBuilderTests.cs b/DomainModeling.Tests/DummyBuilderTests.cs index 1789255..0502d3b 100644 --- a/DomainModeling.Tests/DummyBuilderTests.cs +++ b/DomainModeling.Tests/DummyBuilderTests.cs @@ -19,6 +19,29 @@ public void Build_Regularly_ShouldReturnExpectedResult() Assert.Equal(1m, result.Amount.Amount.Value); } + [Fact] + public void Build_WithReuseOfRecordTypedBuilder_ShouldReturnExpectedResult() + { + var builder = new TestEntityDummyBuilder() + .WithCount(5); + + var result1 = builder + .WithCreationDate(DateOnly.FromDateTime(DateTime.UnixEpoch)) + .Build(); + + var result2 = builder + .WithModificationDateTime(DateTime.UnixEpoch) + .Build(); + + Assert.Equal(5, result1.Count); + Assert.Equal(DateOnly.FromDateTime(DateTime.UnixEpoch), result1.CreationDate); + Assert.NotEqual(DateTime.UnixEpoch, result1.ModificationDateTime); + + Assert.Equal(5, result2.Count); + Assert.NotEqual(DateOnly.FromDateTime(DateTime.UnixEpoch), result2.CreationDate); + Assert.Equal(DateTime.UnixEpoch, result2.ModificationDateTime); + } + [Fact] public void Build_WithCustomizations_ShouldReturnExpectedResult() { @@ -62,7 +85,7 @@ public void Build_WithStringWrapperValueObject_ShouldUseEntityConstructorParamet namespace DummyBuilderTestTypes { [DummyBuilder] - public sealed partial class TestEntityDummyBuilder + public sealed partial record class TestEntityDummyBuilder { // Demonstrate that we can take priority over the generated members public TestEntityDummyBuilder WithCreationDateTime(DateTime value) => this.With(b => b.CreationDateTime = value); diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index c875436..70dbb7c 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -65,6 +65,7 @@ Misc: - Semi-breaking: IFormattable & co for string wrappers have stopped treating null strings as "", as this could cover up mistakes instead of revealing them. - Semi-breaking: IIdentity now implements IWrapperValueObject. - Feature: Non-generic Wrapper/Identity interfaces. +- Feature: DummyBuilder records clone themselves on each step, for reuse. - Feature: Analyzer warns when '==' or similar operator implicitly casts some IValueObject to something else. This avoids accidentally comparing unrelated types. - Fix: Fixed bug where source-generated records would always generate ToString()/Equals()/GetHashCode(), even if you wrote your own. - Fix: Fixed bug where source-generated Wrappers/Identities would not recognize manual member implementations if they were explicit interface implementations. diff --git a/README.md b/README.md index e46e11d..b7c2c73 100644 --- a/README.md +++ b/README.md @@ -227,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 +public record class PaymentDummyBuilder { // Have a default value for each property, along with a fluent method to change it @@ -255,7 +255,7 @@ public class PaymentDummyBuilder } ``` -Test methods avoid constructor invocations, e.g. `new Payment("EUR", 1.00m)`, and instead use the following: +Test methods can then avoid constructor invocations, e.g. `new Payment("EUR", 1.00m)`, and instead use the following: ```cs new PaymentBuilder().Build(); // Completely default instance @@ -278,7 +278,7 @@ Change the type as follows to get source generation for it: ```cs [DummyBuilder] -public partial class PaymentDummyBuilder +public partial record class PaymentDummyBuilder { // Anything defined manually will cause the source generator to outcomment its conflicting code, i.e. manual code always takes precedence @@ -294,6 +294,8 @@ The generated `Build()` method opts for _the most visible, simplest parameterize Dummy builders generally live in a test project, or in a library project consumed solely by test projects. +Note that, if the dummy builder is a record class, a new copy is made on every mutation. This allows a partially constructed builder to be reused in multiple directions. + ## Constructor Validation DDD promotes the validation of domain rules and invariants in the constructors of the domain objects. This pattern is fully supported: From 79364579ca84e687ee6db7fea0b7a9a48bdd6a8c Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:22:17 +0200 Subject: [PATCH 20/23] DefinedEnum for validated enums. --- .../AnalyzerTypeSymbolExtensions.cs | 18 + ...finedEnumMemberWithoutPrimitiveAnalyzer.cs | 86 ++ ...edEnumConversionFromNonConstantAnalyzer.cs | 109 +++ ...licitConversionOnBinaryOperatorAnalyzer.cs | 30 +- ...jectLiftingOnComparisonOperatorAnalyzer.cs | 76 ++ ...dEnumFieldWithoutPrimitiveTypeCodeFixer.cs | 100 ++ ...inModelConfiguratorGenerator.Identities.cs | 2 +- ...nfiguratorGenerator.WrapperValueObjects.cs | 2 +- .../EntityFrameworkConfigurationGenerator.cs | 48 +- DomainModeling.Generator/EnumExtensions.cs | 12 +- DomainModeling.Generator/IdentityGenerator.cs | 11 +- .../ValueWrapperGenerator.cs | 14 +- .../WrapperValueObjectGenerator.cs | 13 +- ...iftingOnComparisonOperatorAnalyzerTests.cs | 27 + ...lueObjectDefaultExpressionAnalyzerTests.cs | 5 + ...ityFrameworkConfigurationGeneratorTests.cs | 47 +- .../Enums/DefinedEnumTests.cs | 851 ++++++++++++++++++ .../Enums/EnumExtensionsTests.cs | 71 ++ DomainModeling.Tests/IdentityTests.cs | 56 +- .../WrapperValueObjectTests.cs | 66 +- .../Conversions/FormattingExtensions.cs | 2 +- .../Conversions/ValueWrapperJsonConverter.cs | 8 +- DomainModeling/DomainModeling.csproj | 21 +- DomainModeling/Enums/DefinedEnum.cs | 608 +++++++++++++ DomainModeling/Enums/EnumExtensions.cs | 136 +++ DomainModeling/Enums/EnumJsonConverter.cs | 91 ++ .../Enums/EnumNewtonsoftJsonConverter.cs | 76 ++ README.md | 167 +++- 28 files changed, 2538 insertions(+), 215 deletions(-) create mode 100644 DomainModeling.Analyzer/AnalyzerTypeSymbolExtensions.cs create mode 100644 DomainModeling.Analyzer/Analyzers/DefinedEnumMemberWithoutPrimitiveAnalyzer.cs create mode 100644 DomainModeling.Analyzer/Analyzers/ImplicitDefinedEnumConversionFromNonConstantAnalyzer.cs create mode 100644 DomainModeling.Analyzer/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzer.cs create mode 100644 DomainModeling.CodeFixProviders/DefinedEnumFieldWithoutPrimitiveTypeCodeFixer.cs create mode 100644 DomainModeling.Tests/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs create mode 100644 DomainModeling.Tests/Enums/DefinedEnumTests.cs create mode 100644 DomainModeling.Tests/Enums/EnumExtensionsTests.cs create mode 100644 DomainModeling/Enums/DefinedEnum.cs create mode 100644 DomainModeling/Enums/EnumExtensions.cs create mode 100644 DomainModeling/Enums/EnumJsonConverter.cs create mode 100644 DomainModeling/Enums/EnumNewtonsoftJsonConverter.cs diff --git a/DomainModeling.Analyzer/AnalyzerTypeSymbolExtensions.cs b/DomainModeling.Analyzer/AnalyzerTypeSymbolExtensions.cs new file mode 100644 index 0000000..d401889 --- /dev/null +++ b/DomainModeling.Analyzer/AnalyzerTypeSymbolExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.CodeAnalysis; + +namespace Architect.DomainModeling.Analyzer; + +internal static class AnalyzerTypeSymbolExtensions +{ + public static bool IsNullable(this ITypeSymbol? potentialNullable, out ITypeSymbol nullableUnderlyingType) + { + if (potentialNullable is not INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } namedTypeSymbol) + { + nullableUnderlyingType = null!; + return false; + } + + nullableUnderlyingType = namedTypeSymbol.TypeArguments[0]; + return true; + } +} diff --git a/DomainModeling.Analyzer/Analyzers/DefinedEnumMemberWithoutPrimitiveAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/DefinedEnumMemberWithoutPrimitiveAnalyzer.cs new file mode 100644 index 0000000..a169f36 --- /dev/null +++ b/DomainModeling.Analyzer/Analyzers/DefinedEnumMemberWithoutPrimitiveAnalyzer.cs @@ -0,0 +1,86 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Architect.DomainModeling.Analyzer.Analyzers; + +/// +/// Enforces the use of DefinedEnum<TEnum, TPrimitive> over DefinedEnum<TEnum> in properties and fields. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DefinedEnumMemberWithoutPrimitiveAnalyzer : DiagnosticAnalyzer +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] + private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( + id: "DefinedEnumMemberMissingPrimitiveSpecification", + title: "DefinedEnum member missing specification of primitive representation", + messageFormat: "DefinedEnum member {0}.{1} must specify its primitive representation using the second generic type parameter", + category: "Design", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics); + + context.RegisterSyntaxNodeAction(AnalyzePropertyDeclaration, SyntaxKind.PropertyDeclaration); + context.RegisterSyntaxNodeAction(AnalyzeFieldDeclaration, SyntaxKind.FieldDeclaration); + } + + private static void AnalyzePropertyDeclaration(SyntaxNodeAnalysisContext context) + { + var propertySyntax = (PropertyDeclarationSyntax)context.Node; + var property = context.SemanticModel.GetDeclaredSymbol(propertySyntax); + + if (property is null) + return; + + WarnAgainstMissingPrimitiveSpecification(context, property.Type, property.ContainingType, propertySyntax.Type); + } + + private static void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context) + { + var fieldSyntax = (FieldDeclarationSyntax)context.Node; + + // Note that fields can be defined like this: + // private int field1, field2, field3; + if (fieldSyntax.Declaration.Variables.Count == 0) // Prevents a NullReferenceException when enumerating the variables + return; + foreach (var fieldVariableSyntax in fieldSyntax.Declaration.Variables) + { + if (context.SemanticModel.GetDeclaredSymbol(fieldVariableSyntax) is not IFieldSymbol field) + continue; + + WarnAgainstMissingPrimitiveSpecification(context, field.Type, field.ContainingType, fieldSyntax.Declaration.Type); + } + } + + private static void WarnAgainstMissingPrimitiveSpecification(SyntaxNodeAnalysisContext context, ITypeSymbol typeSymbol, ITypeSymbol containingType, SyntaxNode locationNode) + { + // Dig through nullable + if (typeSymbol.IsNullable(out var nullableUnderlyingType)) + typeSymbol = nullableUnderlyingType; + + if (typeSymbol is not + INamedTypeSymbol + { + IsGenericType: true, Arity: 1, Name: "DefinedEnum", + ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } + }) + return; + + var diagnostic = Diagnostic.Create( + DiagnosticDescriptor, + locationNode.GetLocation(), + containingType.Name, + typeSymbol.Name); + + context.ReportDiagnostic(diagnostic); + } +} diff --git a/DomainModeling.Analyzer/Analyzers/ImplicitDefinedEnumConversionFromNonConstantAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/ImplicitDefinedEnumConversionFromNonConstantAnalyzer.cs new file mode 100644 index 0000000..b60d382 --- /dev/null +++ b/DomainModeling.Analyzer/Analyzers/ImplicitDefinedEnumConversionFromNonConstantAnalyzer.cs @@ -0,0 +1,109 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Architect.DomainModeling.Analyzer.Analyzers; + +/// +/// Prevents attempts to implicilty convert from a non-constant TEnum value to DefinedEnum<TEnum> or DefinedEnum<TEnum, TPrimitive>. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ImplicitDefinedEnumConversionFromNonConstantAnalyzer : DiagnosticAnalyzer +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] + private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( + id: "ImplicitConversionFromUnvalidatedEnumToDefinedEnum", + title: "Implicit conversion from unvalidated enum to DefinedEnum", + messageFormat: "Only a defined enum constant may be implicitly converted to DefinedEnum. For non-constant values, use DefinedEnum.Create(), a constructor, or an explicit conversion.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + + context.RegisterOperationAction(AnalyzeConversion, OperationKind.Conversion); + } + + private static void AnalyzeConversion(OperationAnalysisContext context) + { + var conversion = (IConversionOperation)context.Operation; + + // Only implicit conversions are relevant to us + if (!conversion.IsImplicit) + return; + + var from = conversion.Operand.Type; + var to = conversion.Type; + + // Dig through nullables + if (from.IsNullable(out var nullableUnderlyingType)) + from = nullableUnderlyingType; + if (to.IsNullable(out nullableUnderlyingType)) + to = nullableUnderlyingType; + + // Only from enum is relevant to us + if (from is not { TypeKind: TypeKind.Enum } enumType) + return; + + // Only to DefinedEnum is relevant to us + if (to is not + INamedTypeSymbol + { + IsGenericType: true, Arity: 1 or 2, Name: "DefinedEnum", + ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } + }) + return; + + // Produce an error if the implicit conversion is not coming from a defined constant value of the enum's type + if (!IsDefinedEnumConstant(enumType, conversion.Operand.ConstantValue)) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptor, + conversion.Syntax.GetLocation()); + + context.ReportDiagnostic(diagnostic); + } + } + + private static bool IsDefinedEnumConstant(ITypeSymbol enumType, Optional constantValue) + { + if (!constantValue.HasValue) + return false; + + if (enumType is not INamedTypeSymbol { EnumUnderlyingType: { } } namedEnumType) + return false; + + var binaryValue = GetBinaryValue(namedEnumType.EnumUnderlyingType, constantValue.Value); + + var valueIsDefined = namedEnumType.GetMembers().Any(member => + member is IFieldSymbol { ConstantValue: var value } && GetBinaryValue(namedEnumType.EnumUnderlyingType, value) == binaryValue); + + return valueIsDefined; + } + + private static ulong? GetBinaryValue(ITypeSymbol enumUnderlyingType, object? value) + { + if (value is null) + return null; + + return (enumUnderlyingType.SpecialType, Type.GetTypeCode(value.GetType())) switch + { + (SpecialType.System_Byte, TypeCode.Byte) => Convert.ToByte(value), + (SpecialType.System_SByte, TypeCode.SByte) => (ulong)Convert.ToSByte(value), + (SpecialType.System_UInt16, TypeCode.UInt16) => Convert.ToUInt16(value), + (SpecialType.System_Int16, TypeCode.Int16) => (ulong)Convert.ToInt16(value), + (SpecialType.System_UInt32, TypeCode.UInt32) => Convert.ToUInt32(value), + (SpecialType.System_Int32, TypeCode.Int32) => (ulong)Convert.ToInt32(value), + (SpecialType.System_UInt64, TypeCode.UInt64) => Convert.ToUInt64(value), + (SpecialType.System_Int64, TypeCode.Int64) => (ulong)Convert.ToInt64(value), + _ => null, + }; + } +} diff --git a/DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs index 854169e..556323a 100644 --- a/DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs +++ b/DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs @@ -55,20 +55,20 @@ private static void AnalyzeBinaryExpression(SyntaxNodeAnalysisContext context) return; // If either operand was implicitly converted FROM some IValueObject to something else, then the comparison is ill-advised - if (OperandWasImplicitlyConvertedFromIValueObject(leftTypeInfo) || OperandWasImplicitlyConvertedFromIValueObject(rightTypeInfo)) + if (OperandWasImplicitlyConvertedFromSomeIValueObject(leftTypeInfo) || OperandWasImplicitlyConvertedFromSomeIValueObject(rightTypeInfo)) { var diagnostic = Diagnostic.Create( DiagnosticDescriptor, context.Node.GetLocation(), binaryExpression.OperatorToken.ValueText, - IsNullable(leftTypeInfo.Type, out var nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : leftTypeInfo.Type.Name, - IsNullable(rightTypeInfo.Type, out nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : rightTypeInfo.Type.Name); + leftTypeInfo.Type.IsNullable(out var nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : leftTypeInfo.Type.Name, + rightTypeInfo.Type.IsNullable(out nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : rightTypeInfo.Type.Name); context.ReportDiagnostic(diagnostic); } } - private static bool OperandWasImplicitlyConvertedFromIValueObject(TypeInfo operandTypeInfo) + private static bool OperandWasImplicitlyConvertedFromSomeIValueObject(TypeInfo operandTypeInfo) { var from = operandTypeInfo.Type; var to = operandTypeInfo.ConvertedType; @@ -79,34 +79,22 @@ private static bool OperandWasImplicitlyConvertedFromIValueObject(TypeInfo opera // Do not flag nullable lifting (where a nullable and a non-nullable are compared) // Note that it LOOKS as though the nullable is converted to non-nullable, but the opposite is true - if (IsNullable(to, out var nullableUnderlyingType) && nullableUnderlyingType.Equals(from, SymbolEqualityComparer.Default)) + if (to.IsNullable(out var nullableUnderlyingType) && nullableUnderlyingType.Equals(from, SymbolEqualityComparer.Default)) return false; // Dig through nullables - if (IsNullable(from, out nullableUnderlyingType)) + if (from.IsNullable(out nullableUnderlyingType)) from = nullableUnderlyingType; - if (IsNullable(to, out nullableUnderlyingType)) + if (to.IsNullable(out nullableUnderlyingType)) to = nullableUnderlyingType; // Backwards compatibility: If converting to ValueObject, then ignore, because the ValueObject base class implements ==(ValueObject, ValueObject) if (to is { Name: "ValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } }) return false; - var isConvertedFromIValueObject = from.AllInterfaces.Any(interf => + var isConvertedFromSomeIValueObject = from.AllInterfaces.Any(interf => interf is { Name: "IValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } }); - return isConvertedFromIValueObject; - } - - private static bool IsNullable(ITypeSymbol? potentialNullable, out ITypeSymbol underlyingType) - { - if (potentialNullable is not INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } namedTypeSymbol) - { - underlyingType = null!; - return false; - } - - underlyingType = namedTypeSymbol.TypeArguments[0]; - return true; + return isConvertedFromSomeIValueObject; } } diff --git a/DomainModeling.Analyzer/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzer.cs new file mode 100644 index 0000000..b8c1d2a --- /dev/null +++ b/DomainModeling.Analyzer/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzer.cs @@ -0,0 +1,76 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Architect.DomainModeling.Analyzer.Analyzers; + +/// +/// Prevents the use of comparison operators with nullables, where lifting causes nulls to be handled without being treated as less than any other value. +/// This avoids a counterintuitive and likely unintended result for comparisons between null and non-null. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ValueObjectLiftingOnComparisonOperatorAnalyzer : DiagnosticAnalyzer +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] + private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( + id: "CounterintuitiveNullHandlingOnLiftedValueObjectComparison", + title: "Comparisons between null and non-null might produce unintended results", + messageFormat: "'Lifted' comparisons do not treat null as less than other values, which may lead to unexpected results. Handle nulls explicitly, or use Comparer.Default.Compare() to treat null as smaller than other values.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction( + AnalyzeBinaryExpression, + SyntaxKind.LessThanExpression, + SyntaxKind.LessThanOrEqualExpression, + SyntaxKind.GreaterThanExpression, + SyntaxKind.GreaterThanOrEqualExpression); + } + + private static void AnalyzeBinaryExpression(SyntaxNodeAnalysisContext context) + { + if (context.Node is not BinaryExpressionSyntax binaryExpression) + return; + + var semanticModel = context.SemanticModel; + var cancellationToken = context.CancellationToken; + + var leftTypeInfo = semanticModel.GetTypeInfo(binaryExpression.Left, cancellationToken); + var rightTypeInfo = semanticModel.GetTypeInfo(binaryExpression.Right, cancellationToken); + + // If either operand is a nullable of some IValueObject, then the comparison is ill-advised + if (OperandIsSomeNullableIValueObject(leftTypeInfo) || OperandIsSomeNullableIValueObject(rightTypeInfo)) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptor, + context.Node.GetLocation()); + + context.ReportDiagnostic(diagnostic); + } + } + + private static bool OperandIsSomeNullableIValueObject(TypeInfo operandTypeInfo) + { + var type = operandTypeInfo.ConvertedType; + + // Note that, for nullables, it can LOOK as if non-nullables are compared, but that is not the case + if (!type.IsNullable(out var nullableUnderlyingType)) + return false; + + var isSomeIValueObject = nullableUnderlyingType.AllInterfaces.Any(interf => + interf is { Name: "IValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } }); + + return isSomeIValueObject; + } +} diff --git a/DomainModeling.CodeFixProviders/DefinedEnumFieldWithoutPrimitiveTypeCodeFixer.cs b/DomainModeling.CodeFixProviders/DefinedEnumFieldWithoutPrimitiveTypeCodeFixer.cs new file mode 100644 index 0000000..667f235 --- /dev/null +++ b/DomainModeling.CodeFixProviders/DefinedEnumFieldWithoutPrimitiveTypeCodeFixer.cs @@ -0,0 +1,100 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Architect.DomainModeling.CodeFixProviders; + +[Shared] +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(DefinedEnumFieldWithoutPrimitiveTypeCodeFixer))] +public sealed class DefinedEnumFieldWithoutPrimitiveTypeCodeFixer : CodeFixProvider +{ + private static readonly ImmutableArray FixableDiagnosticIdConstant = ["DefinedEnumMemberMissingPrimitiveSpecification"]; + + public override ImmutableArray FixableDiagnosticIds => FixableDiagnosticIdConstant; + + public override FixAllProvider? GetFixAllProvider() + { + return null; + } + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var diagnostic = context.Diagnostics.First(diagnostic => diagnostic.Id == FixableDiagnosticIdConstant[0]); + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) return; + + var diagnosticSpan = diagnostic.Location.SourceSpan; + var node = root.FindNode(diagnosticSpan); + + var memberTypeSyntax = node.FirstAncestorOrSelf()?.Type ?? + node.FirstAncestorOrSelf()?.Declaration.Type; + if (memberTypeSyntax is null) return; + + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) return; + + var typeInfo = semanticModel.GetTypeInfo(memberTypeSyntax, context.CancellationToken); + if (typeInfo.Type is not INamedTypeSymbol namedType) return; + + // Dig through nullable + var genericSyntaxToReplace = memberTypeSyntax as GenericNameSyntax; + if (namedType is INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } nullableType && nullableType.TypeArguments[0] is INamedTypeSymbol nullableUnderlyingType && + memberTypeSyntax is NullableTypeSyntax { ElementType: GenericNameSyntax nestedGenericSyntax }) + { + namedType = nullableUnderlyingType; + genericSyntaxToReplace = nestedGenericSyntax; + } + if (genericSyntaxToReplace is null) return; + + if (namedType.ConstructedFrom is not { Name: "DefinedEnum", Arity: 1, ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } }) + return; + + if (namedType.TypeArguments[0] is not INamedTypeSymbol { EnumUnderlyingType: { } enumUnderlyingType }) + return; + + var stringAction = CodeAction.Create( + title: "Add type parameter to represent as string", + createChangedDocument: cancellationToken => CreateChangedDocumentAsync(context, root, genericSyntaxToReplace, memberTypeSyntax, enumUnderlyingType: null), + equivalenceKey: "AddStringTypeParameter"); + + var numericAction = CodeAction.Create( + title: "Add type parameter to represent numerically", + createChangedDocument: cancellationToken => CreateChangedDocumentAsync(context, root, genericSyntaxToReplace, memberTypeSyntax, enumUnderlyingType), + equivalenceKey: "AddNumericTypeParameter"); + + context.RegisterCodeFix(stringAction, diagnostic); + context.RegisterCodeFix(numericAction, diagnostic); + } + + private static Task CreateChangedDocumentAsync(CodeFixContext context, SyntaxNode root, + GenericNameSyntax genericSyntaxToReplace, TypeSyntax memberTypeSyntax, INamedTypeSymbol? enumUnderlyingType) + { + var underlyingTypeSyntax = enumUnderlyingType is not { SpecialType: { } specialType } + ? SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)) + : specialType switch + { + SpecialType.System_Byte => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.ByteKeyword)), + SpecialType.System_SByte => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.SByteKeyword)), + SpecialType.System_Int16 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.ShortKeyword)), + SpecialType.System_UInt16 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.UShortKeyword)), + SpecialType.System_Int32 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword)), + SpecialType.System_UInt32 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.UIntKeyword)), + SpecialType.System_Int64 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.LongKeyword)), + SpecialType.System_UInt64 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.ULongKeyword)), + _ => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword)) + }; + + var enhancedGenericType = genericSyntaxToReplace.WithTypeArgumentList( + genericSyntaxToReplace.TypeArgumentList.WithArguments( + genericSyntaxToReplace.TypeArgumentList.Arguments.Add(underlyingTypeSyntax))); + var enhancedMemberType = memberTypeSyntax is NullableTypeSyntax + ? (TypeSyntax)SyntaxFactory.NullableType(enhancedGenericType).WithTriviaFrom(memberTypeSyntax) + : enhancedGenericType.WithTriviaFrom(memberTypeSyntax); + var newRoot = root.ReplaceNode(memberTypeSyntax, enhancedMemberType); + return Task.FromResult(context.Document.WithSyntaxRoot(newRoot)); + } +} diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs index a8ce5c0..8c917de 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs @@ -19,7 +19,7 @@ internal static void GenerateSourceForIdentities( var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.ValueWrappers .Where(generatable => generatable.IsIdentity) - .Select(generatable => (Generatable: generatable, CoreTypeName: ValueWrapperGenerator.GetCoreTypeFullyQualifiedName(input.ValueWrappers, generatable.TypeName, generatable.ContainingNamespace))) + .Select(generatable => (Generatable: generatable, CoreTypeName: ValueWrapperGenerator.GetDirectParentOfCoreType(input.ValueWrappers, generatable.TypeName, generatable.ContainingNamespace).CoreTypeFullyQualifiedName)) .Select(tuple => $$""" configurator.ConfigureIdentity<{{tuple.Generatable.ContainingNamespace}}.{{tuple.Generatable.TypeName}}, {{tuple.Generatable.UnderlyingTypeFullyQualifiedName}}, {{tuple.CoreTypeName}}>({{Environment.NewLine}} new Architect.DomainModeling.Configuration.IIdentityConfigurator.Args()); """)); diff --git a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs index 98d56bd..37f9504 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs @@ -19,7 +19,7 @@ internal static void GenerateSourceForWrapperValueObjects( var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.ValueWrappers .Where(generatable => !generatable.IsIdentity) - .Select(generatable => (Generatable: generatable, CoreTypeName: ValueWrapperGenerator.GetCoreTypeFullyQualifiedName(input.ValueWrappers, generatable.TypeName, generatable.ContainingNamespace))) + .Select(generatable => (Generatable: generatable, CoreTypeName: ValueWrapperGenerator.GetDirectParentOfCoreType(input.ValueWrappers, generatable.TypeName, generatable.ContainingNamespace).CoreTypeFullyQualifiedName)) .Select(tuple => $$""" configurator.ConfigureWrapperValueObject<{{tuple.Generatable.ContainingNamespace}}.{{tuple.Generatable.TypeName}}, {{tuple.Generatable.UnderlyingTypeFullyQualifiedName}}, {{tuple.CoreTypeName}}>({{Environment.NewLine}} new Architect.DomainModeling.Configuration.IWrapperValueObjectConfigurator.Args()); """)); diff --git a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs index 33d411b..b119c08 100644 --- a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs +++ b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs @@ -143,6 +143,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using Architect.DomainModeling; using Architect.DomainModeling.Configuration; using Architect.DomainModeling.Conversions; +using Architect.DomainModeling.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -360,7 +361,7 @@ file sealed record class ValueWrapperConfigurator( IDatabaseProvider? DatabaseProvider, Action InvokeConfigurationCallbacks, ValueWrapperConfigurationOptions? Options) - : IIdentityConfigurator, IWrapperValueObjectConfigurator, IModelInitializedConvention, IModelFinalizingConvention + : IIdentityConfigurator, IWrapperValueObjectConfigurator, IModelInitializedConvention, IPropertyAddedConvention, IModelFinalizingConvention {{ private Dictionary DesiredCaseSensitivityPerType {{ get; }} = []; @@ -390,6 +391,23 @@ public void ProcessModelInitialized(IConventionModelBuilder modelBuilder, IConve this.InvokeConfigurationCallbacks(); }} + public void ProcessPropertyAdded(IConventionPropertyBuilder propertyBuilder, IConventionContext context) + {{ + // Map DefinedEnum properties + var clrType = propertyBuilder.Metadata.ClrType; + if (clrType.IsConstructedGenericType && clrType.GetGenericTypeDefinition() == typeof(Nullable<>)) + clrType = clrType.GenericTypeArguments[0]; // Dig through nullable + if (clrType.IsConstructedGenericType && clrType.GetGenericTypeDefinition() == typeof(DefinedEnum<,>)) + {{ + var primitiveType = clrType.GenericTypeArguments[1]; + propertyBuilder.HasConverter(typeof(WrapperValueObjectConverter<,>).MakeGenericType(clrType, primitiveType), fromDataAnnotation: true); + if (primitiveType == typeof(string) && propertyBuilder.CanSetMaxLength(64)) + propertyBuilder.HasMaxLength(64); // A reasonable maximum, which the developer can choose to override per property + if (primitiveType == typeof(string) && ValueWrapperConfigurator.GetApplicableCollationFromOptions(StringComparison.OrdinalIgnoreCase, this.Options) is string targetCollation) + propertyBuilder.UseCollation(targetCollation, fromDataAnnotation: true); // Ignore-case for convenience + }} + }} + public void ConfigureIdentity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, TUnderlying, TCore>( in IIdentityConfigurator.Args args) where TIdentity : IIdentity, IDirectValueWrapper, ICoreValueWrapper @@ -631,20 +649,6 @@ file sealed record class EntityFrameworkWrapperValueObjectConfigurator( .UseCollation(targetCollation); }} }} - - [CompilerGenerated] - private sealed class WrapperValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> - : ValueConverter - where TModel : IValueWrapper - {{ - public WrapperValueObjectConverter() - : base( - model => DomainObjectSerializer.Serialize(model)!, - provider => DomainObjectSerializer.Deserialize(provider)!, - mappingHints: null) - {{ - }} - }} }} [CompilerGenerated] @@ -735,6 +739,20 @@ public override InstantiationBinding With(IReadOnlyList parame }} }} + [CompilerGenerated] + file sealed class WrapperValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> + : ValueConverter + where TModel : IValueWrapper + {{ + public WrapperValueObjectConverter() + : base( + model => DomainObjectSerializer.Serialize(model)!, + provider => DomainObjectSerializer.Deserialize(provider)!, + mappingHints: null) + {{ + }} + }} + [CompilerGenerated] file sealed class OrdinalStringComparer : ValueComparer {{ diff --git a/DomainModeling.Generator/EnumExtensions.cs b/DomainModeling.Generator/EnumExtensions.cs index 6234962..e4a85da 100644 --- a/DomainModeling.Generator/EnumExtensions.cs +++ b/DomainModeling.Generator/EnumExtensions.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; using Microsoft.CodeAnalysis; namespace Architect.DomainModeling.Generator; @@ -117,7 +116,16 @@ private static ulong GetNumericValue(T enumValue) where T : unmanaged, Enum { var result = 0UL; - Unsafe.WriteUnaligned(ref Unsafe.As(ref result), enumValue); + + // Since the actual value may be smaller than ulong's 8 bytes, we must align to the least significant byte + // This way, casting the ulong back to the original type gets back the exact original bytes + // On little-endian, that means aligning to the left of the bytes + // On big-endian, that means aligning to the right of the bytes + if (BitConverter.IsLittleEndian) + Unsafe.WriteUnaligned(ref Unsafe.As(ref result), enumValue); + else + Unsafe.WriteUnaligned(ref Unsafe.Add(ref Unsafe.As(ref result), sizeof(ulong) - Unsafe.SizeOf()), enumValue); + return result; } } diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index 9cb8ffd..ab06b55 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -467,7 +467,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var hasIdentityValueObjectAttribute = generatable.IdTypeExists; var directParentOfCore = ValueWrapperGenerator.GetDirectParentOfCoreType(valueWrappers, idTypeName, containingNamespace); - var coreTypeFullyQualifiedName = directParentOfCore.CustomCoreTypeFullyQualifiedName ?? directParentOfCore.UnderlyingTypeFullyQualifiedName ?? generatable.UnderlyingTypeFullyQualifiedName; + var coreTypeFullyQualifiedName = directParentOfCore.CoreTypeFullyQualifiedName ?? generatable.UnderlyingTypeFullyQualifiedName; var coreTypeIsStruct = directParentOfCore.CoreTypeIsStruct; (var coreValueIsNonNull, var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( @@ -600,11 +600,10 @@ public int CompareTo({idTypeName} other) {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "//" : "")}public static bool operator ==({idTypeName} left, {idTypeName} right) => left.Equals(right); {(existingComponents.HasFlags(IdTypeComponents.NotEqualsOperator) ? "//" : "")}public static bool operator !=({idTypeName} left, {idTypeName} right) => !(left == right); - // Nullable comparison operators circumvent the unexpected behavior that would be caused by .NET's lifting - {(existingComponents.HasFlags(IdTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({idTypeName}? left, {idTypeName}? right) => left is {{ }} one && !(right is {{ }} two && one.CompareTo(two) <= 0); - {(existingComponents.HasFlags(IdTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({idTypeName}? left, {idTypeName}? right) => right is {{ }} two && !(left is {{ }} one && one.CompareTo(two) >= 0); - {(existingComponents.HasFlags(IdTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({idTypeName}? left, {idTypeName}? right) => !(left < right); - {(existingComponents.HasFlags(IdTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({idTypeName}? left, {idTypeName}? right) => !(left > right); + {(existingComponents.HasFlags(IdTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({idTypeName} left, {idTypeName} right) => left.CompareTo(right) > 0; + {(existingComponents.HasFlags(IdTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({idTypeName} left, {idTypeName} right) => left.CompareTo(right) < 0; + {(existingComponents.HasFlags(IdTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({idTypeName} left, {idTypeName} right) => !(left < right); + {(existingComponents.HasFlags(IdTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({idTypeName} left, {idTypeName} right) => !(left > right); {(existingComponents.HasFlags(IdTypeComponents.ConvertToOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } ? "" diff --git a/DomainModeling.Generator/ValueWrapperGenerator.cs b/DomainModeling.Generator/ValueWrapperGenerator.cs index 6f7bd39..d10d92b 100644 --- a/DomainModeling.Generator/ValueWrapperGenerator.cs +++ b/DomainModeling.Generator/ValueWrapperGenerator.cs @@ -58,7 +58,7 @@ internal static BasicGeneratable GetDirectParentOfCoreType( { couldDigDeeper = true; result = ref item; - nextTypeName = (item.CustomCoreTypeFullyQualifiedName ?? item.UnderlyingTypeFullyQualifiedName).AsSpan(); + nextTypeName = item.CoreTypeFullyQualifiedName.AsSpan(); break; } } @@ -74,7 +74,7 @@ internal static string GetCoreTypeFullyQualifiedName( string typeName, string containingNamespace) { var directParentOfCoreType = GetDirectParentOfCoreType(valueWrappers, typeName, containingNamespace); - return directParentOfCoreType.CustomCoreTypeFullyQualifiedName ?? directParentOfCoreType.UnderlyingTypeFullyQualifiedName; + return directParentOfCoreType.CoreTypeFullyQualifiedName; } // ATTENTION: This method cannot be combined with the other recursive one, because this one's results are affected by intermediate items, not just the deepest item @@ -134,10 +134,9 @@ internal readonly record struct BasicGeneratable public string ContainingNamespace { get; } public string UnderlyingTypeFullyQualifiedName { get; } /// - /// Set only if manually chosen by the developer. /// Helps implement wrappers around unofficial wrapper types, such as a WrapperValueObject<Uri> that pretends its core type is . /// - public string? CustomCoreTypeFullyQualifiedName { get; } + public string CoreTypeFullyQualifiedName { get; } public bool CoreTypeIsStruct { get; } /// /// A core Value property declared as non-null is a desirable property to propagate, such as to return a non-null value from a conversion operator. @@ -155,13 +154,16 @@ public BasicGeneratable( ITypeSymbol underlyingType, ITypeSymbol? customCoreType) { - var coreType = customCoreType ?? underlyingType; + var coreType = + customCoreType ?? + underlyingType.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[1] ?? + underlyingType; this.IsIdentity = isIdentity; this.TypeName = wrapperType.Name; this.ContainingNamespace = containingNamespace; this.UnderlyingTypeFullyQualifiedName = underlyingType.ToString(); - this.CustomCoreTypeFullyQualifiedName = customCoreType?.ToString(); + this.CoreTypeFullyQualifiedName = coreType.ToString(); this.CoreTypeIsStruct = coreType.IsValueType; this.CoreValueCouldBeNull = !CoreValueIsReachedAsNonNull(wrapperType); this.IsSpanFormattable = underlyingType.SpecialType == SpecialType.System_String || underlyingType.AllInterfaces.Any(interf => diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index db2bd5f..6d0a55e 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -396,7 +396,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var existingComponents = generatable.ExistingComponents; var directParentOfCore = ValueWrapperGenerator.GetDirectParentOfCoreType(valueWrappers, generatable.TypeName, generatable.ContainingNamespace); - var coreTypeFullyQualifiedName = directParentOfCore.CustomCoreTypeFullyQualifiedName ?? directParentOfCore.UnderlyingTypeFullyQualifiedName ?? generatable.UnderlyingTypeFullyQualifiedName; + var coreTypeFullyQualifiedName = directParentOfCore.CoreTypeFullyQualifiedName ?? generatable.UnderlyingTypeFullyQualifiedName; var coreTypeIsStruct = directParentOfCore.CoreTypeIsStruct; (var coreValueIsNonNull, var isSpanFormattable, var isSpanParsable, var isUtf8SpanFormattable, var isUtf8SpanParsable) = ValueWrapperGenerator.GetFormattabilityAndParsabilityRecursively( @@ -481,7 +481,7 @@ namespace {containingNamespace} /// Accepts a nullable parameter, but throws for null values. /// For example, this is useful for a mandatory request input where omission must lead to rejection. /// - public {typeName}({underlyingTypeFullyQualifiedName}? value) + public {typeName}([DisallowNull] {underlyingTypeFullyQualifiedName}? value) : this(value ?? throw new ArgumentNullException(nameof(value))) {{ }} @@ -546,11 +546,10 @@ public int CompareTo({typeName}{(generatable.IsClass ? "?" : "")} other) {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NotEqualsOperator) ? "//" : "")}public static bool operator !=({typeName}{(generatable.IsClass ? "?" : "")} left, {typeName}{(generatable.IsClass ? "?" : "")} right) => !(left == right); {(isComparable ? "" : "/*")} - // Nullable comparison operators circumvent the unexpected behavior that would be caused by .NET's lifting - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({typeName}? left, {typeName}? right) => left is {{ }} one && !(right is {{ }} two && one.CompareTo(two) <= 0); - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({typeName}? left, {typeName}? right) => right is {{ }} two && !(left is {{ }} one && one.CompareTo(two) >= 0); - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({typeName}? left, {typeName}? right) => !(left < right); - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({typeName}? left, {typeName}? right) => !(left > right); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterThanOperator) ? "//" : "")}public static bool operator >({typeName} left, {typeName} right) => left.CompareTo(right) > 0; + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessThanOperator) ? "//" : "")}public static bool operator <({typeName} left, {typeName} right) => left.CompareTo(right) < 0; + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GreaterEqualsOperator) ? "//" : "")}public static bool operator >=({typeName} left, {typeName} right) => !(left < right); + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.LessEqualsOperator) ? "//" : "")}public static bool operator <=({typeName} left, {typeName} right) => !(left > right); {(isComparable ? "" : "*/")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ConvertToOperator) ? "//" : "")}{(generatable is { UnderlyingTypeIsInterface: true } or { IsClass: true, UnderlyingCanBeNull: true, } diff --git a/DomainModeling.Tests/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs b/DomainModeling.Tests/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs new file mode 100644 index 0000000..ba47675 --- /dev/null +++ b/DomainModeling.Tests/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs @@ -0,0 +1,27 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using Architect.DomainModeling.Tests.IdentityTestTypes; +using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; + +namespace Architect.DomainModeling.Tests.Analyzers; + +[SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] +[SuppressMessage("Usage", "CounterintuitiveNullHandlingOnLiftedValueObjectComparison:Comparisons between null and non-null might produce unintended results", Justification = "Testing presence of warning.")] +public class ValueObjectLiftingOnComparisonOperatorAnalyzerTests +{ + // Unfortunately, we always get "unnecessary suppression" even when the warning is successfully suppressed + // All we can do is manually outcomment the suppression temporarily to check that each statement in this file still warns + + public static void CompareUnrelatedValueObjects_WithLifting_ShouldWarn() + { +#pragma warning disable CS0464 // Comparing with null of struct type always produces 'false' -- We still want to test our analyzer on this syntax + + _ = new DefinedEnum(HttpStatusCode.OK) > (HttpStatusCode?)null; + _ = (HttpStatusCode?)null > new DefinedEnum(HttpStatusCode.OK); + _ = new IntId(1) > null; + _ = null > new IntId(1); + _ = new DecimalValue(1) > null; + +#pragma warning restore CS0464 // Comparing with null of struct type always produces 'false' + } +} diff --git a/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs b/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs index 1b60ea2..130bbb4 100644 --- a/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs +++ b/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; namespace Architect.DomainModeling.Tests.Analyzers; @@ -13,11 +14,15 @@ public class WrapperValueObjectDefaultExpressionAnalyzerTests public static void UseDefaultExpressionOnWrapperValueObjectStruct_Always_ShouldWarn() { _ = default(DecimalValue); + _ = default(DefinedEnum); } public static void UseDefaultLiteralOnWrapperValueObjectStruct_Always_ShouldWarn() { DecimalValue value = default; _ = value; + + DefinedEnum definedEnum = default; + _ = definedEnum; } } diff --git a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs index 4eea737..fe180fc 100644 --- a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs +++ b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using Architect.DomainModeling.Configuration; using Architect.DomainModeling.Conversions; using Architect.DomainModeling.Tests.Common; @@ -46,7 +47,11 @@ public void ConfigureConventions_WithAllExtensionsCalled_ShouldBeAbleToWorkWithA new FormatAndParseTestingIntId(3), new LazyStringWrapper(new Lazy("4")), new LazyIntWrapper(new Lazy(5)), - new NumericStringId("6")); + new NumericStringId("6"), + DefinedEnum.Create(HttpStatusCode.OK), + DefinedEnum.Create(HttpStatusCode.Accepted), + DefinedEnum.Create(HttpStatusCode.Created), + null); var entity = new EntityForEF(values); var domainEvent = new DomainEventForEF(id: 2, ignored: null!); @@ -79,7 +84,11 @@ public void ConfigureConventions_WithAllExtensionsCalled_ShouldBeAbleToWorkWithA Assert.Equal(3, reloadedEntity.Values.Three.Value?.Value.Value); Assert.Equal("4", reloadedEntity.Values.Four.Value.Value); Assert.Equal(5, reloadedEntity.Values.Five.Value.Value); - Assert.Equal("6", reloadedEntity.Values.Six.Value); + Assert.Equal("6", reloadedEntity.Values.Six?.Value); + Assert.Equal(HttpStatusCode.OK, reloadedEntity.Values.Seven.Value); + Assert.Equal(HttpStatusCode.Accepted, reloadedEntity.Values.Eight.Value); + Assert.Equal(HttpStatusCode.Created, reloadedEntity.Values.Nine?.Value); + Assert.Null(reloadedEntity.Values.Ten); // This property should be mapped to int via ICoreValueWrapper var mappingForStringWithCustomIntCore = this.DbContext.Model.FindEntityType(typeof(EntityForEF))?.FindNavigation(nameof(EntityForEF.Values))?.TargetEntityType @@ -116,9 +125,9 @@ internal sealed class TestDbContext( [SuppressMessage("Usage", "CA2263:Prefer generic overload when type is known", Justification = "We have no generic info for types received from callbacks.")] protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { - configurationBuilder.Conventions.Remove(); - configurationBuilder.Conventions.Remove(); - configurationBuilder.Conventions.Remove(); + configurationBuilder.Conventions.Remove(typeof(ConstructorBindingConvention)); + configurationBuilder.Conventions.Remove(typeof(RelationshipDiscoveryConvention)); + configurationBuilder.Conventions.Remove(typeof(PropertyDiscoveryConvention)); configurationBuilder.ConfigureDomainModelConventions(domainModel => { @@ -168,6 +177,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) values.Property(x => x.Four); values.Property(x => x.Five); values.Property(x => x.Six); + values.Property(x => x.Seven); + values.Property(x => x.Eight); + values.Property(x => x.Nine); + values.Property(x => x.Ten); }); builder.HasKey(x => x.Id); @@ -314,9 +327,23 @@ internal sealed partial class ValueObjectForEF public FormatAndParseTestingIntId Three { get; private init; } public LazyStringWrapper Four { get; private init; } public LazyIntWrapper Five { get; private init; } - public NumericStringId Six { get; private init; } - - public ValueObjectForEF(Wrapper1ForEF one, Wrapper2ForEF two, FormatAndParseTestingIntId three, LazyStringWrapper four, LazyIntWrapper five, NumericStringId six) + public NumericStringId? Six { get; private init; } + public DefinedEnum Seven { get; private init; } + public DefinedEnum Eight { get; private init; } + public DefinedEnum? Nine { get; private init; } + public DefinedEnum? Ten { get; private init; } + + public ValueObjectForEF( + Wrapper1ForEF one, + Wrapper2ForEF two, + FormatAndParseTestingIntId three, + LazyStringWrapper four, + LazyIntWrapper five, + NumericStringId? six, + DefinedEnum seven, + HttpStatusCode eight, + DefinedEnum? nine, + HttpStatusCode? ten) { if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors) throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors."); @@ -327,5 +354,9 @@ public ValueObjectForEF(Wrapper1ForEF one, Wrapper2ForEF two, FormatAndParseTest this.Four = four; this.Five = five; this.Six = six; + this.Seven = seven; + this.Eight = DefinedEnum.Create(eight); + this.Nine = nine; + this.Ten = (DefinedEnum?)ten; } } diff --git a/DomainModeling.Tests/Enums/DefinedEnumTests.cs b/DomainModeling.Tests/Enums/DefinedEnumTests.cs new file mode 100644 index 0000000..e47a91f --- /dev/null +++ b/DomainModeling.Tests/Enums/DefinedEnumTests.cs @@ -0,0 +1,851 @@ +using System.Diagnostics.CodeAnalysis; +using Architect.DomainModeling.Conversions; +using Xunit; +using Enum = Architect.DomainModeling.DefinedEnum; +using IntEnum = Architect.DomainModeling.DefinedEnum; +using IntWrapper = Architect.DomainModeling.IValueWrapper, int>; +using StringEnum = Architect.DomainModeling.DefinedEnum; +using StringWrapper = Architect.DomainModeling.IValueWrapper, string>; + +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace Architect.DomainModeling.Tests; + +public class DefinedEnumTests +{ + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [SuppressMessage("Design", "CA1069:Enums values should not be duplicated", Justification = "We need to test the behavior under these circumstances.")] + private enum SimpleSByte : sbyte + { + Zero = 0, + One = 1, + AlsoOne = 1, + MinusOne = -1, + } + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [SuppressMessage("Design", "CA1069:Enums values should not be duplicated", Justification = "We need to test the behavior under these circumstances.")] + private enum AdvancedSByte : sbyte + { + Zero = 0, + One = 1, + AlsoOne = 1, + Two = 2, + MinusSixFive = -65, // (sbyte)191 + MinusTwo = -2, + MinusOne = -1, + } + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [SuppressMessage("Design", "CA1069:Enums values should not be duplicated", Justification = "We need to test the behavior under these circumstances.")] + private enum SimpleShort : short + { + Zero = 0, + One = 1, + AlsoOne = 1, + Two = 2, + OneNineOne = 191, + MinusTwo = -2, + MinusOne = -1, + } + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [SuppressMessage("Design", "CA1069:Enums values should not be duplicated", Justification = "We need to test the behavior under these circumstances.")] + private enum AdvancedShort : short + { + Zero = 0, + One = 1, + AlsoOne = 1, + Two = 2, + OneNineOne = 191, + MinusOneSixThreeNineSeven = -16397, // (short)49139 + MinusTwo = -2, + MinusOne = -1, + } + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [SuppressMessage("Design", "CA1069:Enums values should not be duplicated", Justification = "We need to test the behavior under these circumstances.")] + [Flags] + private enum ExampleFlags : uint + { + Zero = 0, + One = 1, + AlsoOne = 1, + Two = 2, + Four = 4, + Sixteen = 16, + } + + static DefinedEnumTests() + { + DefinedEnum.ExceptionFactoryForNullInput = type => new InvalidOperationException($"{type.Name} expects a non-null value."); + DefinedEnum.ExceptionFactoryForUndefinedInput = (type, numericValue) => new InvalidOperationException($"{type.Name} expects a defined value."); + } + + private static Enum ToEnum(int value) + { + return new Enum((LazyThreadSafetyMode)value); + } + + private static IntEnum ToIntEnum(int value) + { + return new IntEnum((LazyThreadSafetyMode)value); + } + + private static StringEnum ToStringEnum(int value) + { + return new StringEnum((LazyThreadSafetyMode)value); + } + + private static Enum? ToEnum(int? value) + { + return value is null ? null : new Enum((LazyThreadSafetyMode)value); + } + + private static IntEnum? ToIntEnum(int? value) + { + return value is null ? null : new IntEnum((LazyThreadSafetyMode)value); + } + + private static StringEnum? ToStringEnum(int? value) + { + return value is null ? null : new StringEnum((LazyThreadSafetyMode)value); + } + + [Fact] + public void UndefinedValue_Regularly_ShouldReturnExpectedResult() + { + Assert.Equal(~(LazyThreadSafetyMode)(1 | 2), Enum.UndefinedValue); + Assert.Equal((SimpleSByte)(-65), DefinedEnum.UndefinedValue); + Assert.Equal((AdvancedSByte)3, DefinedEnum.UndefinedValue); + Assert.Equal((SimpleShort)(-16397), DefinedEnum.UndefinedValue); + Assert.Equal((AdvancedShort)3, DefinedEnum.UndefinedValue); + Assert.Equal(~(ExampleFlags)(1 | 2 | 4 | 16), DefinedEnum.UndefinedValue); + } + + [Fact] + public void ThrowUndefinedInput_Regularly_ShouldThrow() + { + Assert.Throws(() => Enum.ThrowUndefinedInput()); + } + + [Fact] + public void Construct_WithNull_ShouldThrowConfiguredException() + { + Assert.Throws(() => new Enum(null!)); + Assert.Throws(() => new IntEnum(null!)); + Assert.Throws(() => new StringEnum(null!)); + } + + [Fact] + public void Construct_WithUndefinedValue_ShouldThrowConfiguredException() + { + Assert.Throws(() => new Enum((LazyThreadSafetyMode)(-1))); + Assert.Throws(() => new IntEnum((LazyThreadSafetyMode)(-1))); + Assert.Throws(() => new StringEnum((LazyThreadSafetyMode)(-1))); + } + + [Fact] + public void Construct_WithUndefinedBitForFlags_ShouldThrowConfiguredException() + { + Assert.Throws(() => new DefinedEnum((ExampleFlags)8)); + Assert.Throws(() => new DefinedEnum((ExampleFlags)8)); + Assert.Throws(() => new DefinedEnum((ExampleFlags)8)); + } + + [Fact] + public void Construct_Regularly_ShouldHaveExpectedValue() + { + Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new Enum(LazyThreadSafetyMode.PublicationOnly).Value); + Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new IntEnum(LazyThreadSafetyMode.PublicationOnly).Value); + Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new StringEnum(LazyThreadSafetyMode.PublicationOnly).Value); + } + + [Fact] + public void Construct_FromNullableValue_ShouldHaveExpectedValue() + { + Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new Enum((LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly).Value); + Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new IntEnum((LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly).Value); + Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new StringEnum((LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly).Value); + } + + [Fact] + public void Construct_WithValidFlags_ShouldHaveExpectedValue() + { + Assert.Equal(ExampleFlags.One | ExampleFlags.Sixteen, new DefinedEnum(ExampleFlags.Zero | ExampleFlags.One | ExampleFlags.Sixteen).Value); + Assert.Equal(ExampleFlags.One | ExampleFlags.Sixteen, new DefinedEnum(ExampleFlags.Zero | ExampleFlags.One | ExampleFlags.Sixteen).Value); + Assert.Equal(ExampleFlags.One | ExampleFlags.Sixteen, new DefinedEnum(ExampleFlags.Zero | ExampleFlags.One | ExampleFlags.Sixteen).Value); + } + + [Fact] + public void ToString_Regularly_ShouldReturnExpectedResult() + { + Assert.Equal("PublicationOnly", ToEnum(1).ToString()); + Assert.Equal("PublicationOnly", ToIntEnum(1).ToString()); + Assert.Equal("PublicationOnly", ToStringEnum(1).ToString()); + } + + [Fact] + public void GetHashCode_Regulary_ShouldReturnExpectedResult() + { + Assert.Equal(1.GetHashCode(), ToEnum(1).GetHashCode()); + Assert.Equal(1.GetHashCode(), ToIntEnum(1).GetHashCode()); + Assert.Equal(1.GetHashCode(), ToStringEnum(1).GetHashCode()); + } + + [Fact] + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [SuppressMessage("Design", "WrapperValueObjectDefaultExpression:Default expression instantiating unvalidated value object", Justification = "Needs to work, despite being bad practice.")] + public void GetHashCode_WithDefaultInstance_ShouldReturnExpectedResult() + { + var instance = default(Enum); + Assert.Equal(0, instance.GetHashCode()); + var intInstance = default(IntEnum); + Assert.Equal(0, intInstance.GetHashCode()); + var stringInstance = default(StringEnum); + Assert.Equal(0, stringInstance.GetHashCode()); + } + + [Theory] + [InlineData(0, 0, true)] + [InlineData(0, 1, false)] + [InlineData(1, 2, false)] + public void Equals_Regularly_ShouldReturnExpectedResult(int one, int two, bool expectedResult) + { + Assert.Equal(expectedResult, ToEnum(one).Equals(ToEnum(two))); + Assert.Equal(expectedResult, ToIntEnum(one).Equals(ToIntEnum(two))); + Assert.Equal(expectedResult, ToStringEnum(one).Equals(ToStringEnum(two))); + + Assert.Equal(expectedResult, ToEnum(one).Equals(ToIntEnum(two))); + Assert.Equal(expectedResult, ToEnum(one).Equals(ToStringEnum(two))); + Assert.Equal(expectedResult, ToIntEnum(one).Equals(ToEnum(two))); + Assert.Equal(expectedResult, ToIntEnum(one).Equals(ToStringEnum(two))); + Assert.Equal(expectedResult, ToStringEnum(one).Equals(ToEnum(two))); + Assert.Equal(expectedResult, ToStringEnum(one).Equals(ToIntEnum(two))); + } + + [Fact] + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [SuppressMessage("Design", "WrapperValueObjectDefaultExpression:Default expression instantiating unvalidated value object", Justification = "Needs to work, despite being bad practice.")] + public void Equals_WithDefaultInstance_ShouldReturnExpectedResult() + { + Assert.NotEqual(default, ToEnum(1)); + Assert.NotEqual(ToEnum(1), default); + + Assert.NotEqual(default, ToIntEnum(1)); + Assert.NotEqual(ToIntEnum(1), default); + + Assert.NotEqual(default, ToStringEnum(1)); + Assert.NotEqual(ToStringEnum(1), default); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(0, 1)] + [InlineData(1, 2)] + public void EqualityOperator_Regularly_ShouldMatchEquals(int one, int two) + { + Assert.Equal(ToEnum(one).Equals(ToEnum(two)), ToEnum(one) == ToEnum(two)); + Assert.Equal(ToEnum(one).Equals(ToEnum(two)), ToEnum(one) == ToEnum(two).Value); + Assert.Equal(ToEnum(one).Equals(ToEnum(two)), ToEnum(one).Value == ToEnum(two)); + + Assert.Equal(ToIntEnum(one).Equals(ToIntEnum(two)), ToIntEnum(one) == ToIntEnum(two)); + Assert.Equal(ToIntEnum(one).Equals(ToIntEnum(two)), ToIntEnum(one) == ToIntEnum(two).Value); + Assert.Equal(ToIntEnum(one).Equals(ToIntEnum(two)), ToIntEnum(one).Value == ToIntEnum(two)); + + Assert.Equal(ToStringEnum(one).Equals(ToStringEnum(two)), ToStringEnum(one) == ToStringEnum(two)); + Assert.Equal(ToStringEnum(one).Equals(ToStringEnum(two)), ToStringEnum(one) == ToStringEnum(two).Value); + Assert.Equal(ToStringEnum(one).Equals(ToStringEnum(two)), ToStringEnum(one).Value == ToStringEnum(two)); + + Assert.Equal(ToEnum(one).Equals(ToIntEnum(two)), ToEnum(one) == ToIntEnum(two)); + Assert.Equal(ToEnum(one).Equals(ToStringEnum(two)), ToEnum(one) == ToStringEnum(two)); + Assert.Equal(ToIntEnum(one).Equals(ToEnum(two)), ToIntEnum(one) == ToEnum(two)); + Assert.Equal(ToStringEnum(one).Equals(ToEnum(two)), ToStringEnum(one) == ToEnum(two)); + + // Cannot compare directly between different primitive representations - can simply take their values for such comparisons + //Assert.Equal(ToIntEnum(one).Equals(ToStringEnum(two)), ToIntEnum(one) == ToStringEnum(two)); + //Assert.Equal(ToStringEnum(one).Equals(ToIntEnum(two)), ToStringEnum(one) == ToIntEnum(two)); + } + + [Fact] + public void EqualityOperator_WithNullables_ShouldReturnExpectedResult() + { +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable IDE0004 // Remove unnecessary cast -- Deliberate casts to test specific operators +#pragma warning disable CS8073 // The result of the expression is always 'false' (or 'true') -- Deliberate casts to test specific operators +#pragma warning disable xUnit2024 // Do not use boolean asserts for simple equality tests -- Deliberate test of specific operators + Assert.True((Enum?)null == (Enum?)null); + Assert.True((IntEnum?)null == (IntEnum?)null); + Assert.True((StringEnum?)null == (StringEnum?)null); + + Assert.False((Enum?)null == LazyThreadSafetyMode.PublicationOnly); + Assert.False((IntEnum?)null == LazyThreadSafetyMode.PublicationOnly); + Assert.False((StringEnum?)null == LazyThreadSafetyMode.PublicationOnly); + Assert.True((Enum?)ToEnum(1) == LazyThreadSafetyMode.PublicationOnly); + Assert.True((IntEnum?)ToIntEnum(1) == LazyThreadSafetyMode.PublicationOnly); + Assert.True((StringEnum?)ToStringEnum(1) == LazyThreadSafetyMode.PublicationOnly); + + // Analyzers disallows implicit conversion to DefinedEnum from non-constant + //Assert.False((Enum?)null == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + //Assert.False((IntEnum?)null == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + //Assert.False((StringEnum?)null == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + //Assert.True((Enum?)ToEnum(1) == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + //Assert.True((IntEnum?)ToIntEnum(1) == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + //Assert.True((StringEnum?)ToStringEnum(1) == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + + Assert.False((Enum?)null == (Enum?)ToEnum(0)); + Assert.False((IntEnum?)null == (IntEnum?)ToIntEnum(0)); + Assert.False((StringEnum?)null == (StringEnum?)ToStringEnum(0)); + Assert.False((Enum?)ToEnum(0) == (Enum?)null); + Assert.False((IntEnum?)ToIntEnum(0) == (IntEnum?)null); + Assert.False((StringEnum?)ToStringEnum(0) == (StringEnum?)null); + + Assert.True((Enum?)ToEnum(0) == (Enum?)ToEnum(0)); + Assert.True((IntEnum?)ToIntEnum(0) == (IntEnum?)ToIntEnum(0)); + Assert.True((StringEnum?)ToStringEnum(0) == (StringEnum?)ToStringEnum(0)); + + // Comparisons between nullables of different enum-related types are ambiguous, which is easy enough to work around by using their values + //Assert.True((Enum?)ToEnum(0) == (IntEnum?)ToIntEnum(0)); + //Assert.True((Enum?)ToEnum(0) == (StringEnum?)ToStringEnum(0)); + //Assert.True((IntEnum?)ToIntEnum(0) == (Enum?)ToEnum(0)); + //Assert.True((StringEnum?)ToStringEnum(0) == (Enum?)ToEnum(0)); + //Assert.True((IntEnum?)ToIntEnum(0) == (StringEnum?)ToStringEnum(0)); + //Assert.True((StringEnum?)ToStringEnum(0) == (IntEnum?)ToIntEnum(0)); +#pragma warning restore xUnit2024 +#pragma warning restore CS8073 +#pragma warning restore IDE0004 +#pragma warning restore IDE0079 + } + + [Fact] + public void InequalityOperator_WithNullables_ShouldReturnExpectedResult() + { +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable IDE0004 // Remove unnecessary cast -- Deliberate casts to test specific operators +#pragma warning disable CS8073 // The result of the expression is always 'false' (or 'true') -- Deliberate casts to test specific operators +#pragma warning disable xUnit2024 // Do not use boolean asserts for simple equality tests -- Deliberate test of specific operators + Assert.False((Enum?)null != (Enum?)null); + Assert.False((IntEnum?)null != (IntEnum?)null); + Assert.False((StringEnum?)null != (StringEnum?)null); + + Assert.True((Enum?)null != LazyThreadSafetyMode.PublicationOnly); + Assert.True((IntEnum?)null != LazyThreadSafetyMode.PublicationOnly); + Assert.True((StringEnum?)null != LazyThreadSafetyMode.PublicationOnly); + Assert.False((Enum?)ToEnum(1) != LazyThreadSafetyMode.PublicationOnly); + Assert.False((IntEnum?)ToIntEnum(1) != LazyThreadSafetyMode.PublicationOnly); + Assert.False((StringEnum?)ToStringEnum(1) != LazyThreadSafetyMode.PublicationOnly); + + // Analyzers disallows implicit conversion to DefinedEnum from non-constant + //Assert.True((Enum?)null != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + //Assert.True((IntEnum?)null != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + //Assert.True((StringEnum?)null != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + //Assert.False((Enum?)ToEnum(1) != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + //Assert.False((IntEnum?)ToIntEnum(1) != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + //Assert.False((StringEnum?)ToStringEnum(1) != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); + + Assert.True((Enum?)null != (Enum?)ToEnum(0)); + Assert.True((IntEnum?)null != (IntEnum?)ToIntEnum(0)); + Assert.True((StringEnum?)null != (StringEnum?)ToStringEnum(0)); + Assert.True((Enum?)ToEnum(0) != (Enum?)null); + Assert.True((IntEnum?)ToIntEnum(0) != (IntEnum?)null); + Assert.True((StringEnum?)ToStringEnum(0) != (StringEnum?)null); + + Assert.False((Enum?)ToEnum(0) != (Enum?)ToEnum(0)); + Assert.False((IntEnum?)ToIntEnum(0) != (IntEnum?)ToIntEnum(0)); + Assert.False((StringEnum?)ToStringEnum(0) != (StringEnum?)ToStringEnum(0)); + + // Comparisons between nullables of different enum-related types are ambiguous, which is easy enough to work around by using their values + //Assert.False((Enum?)ToEnum(0) != (IntEnum?)ToIntEnum(0)); + //Assert.False((Enum?)ToEnum(0) != (StringEnum?)ToStringEnum(0)); + //Assert.False((IntEnum?)ToIntEnum(0) != (Enum?)ToEnum(0)); + //Assert.False((StringEnum?)ToStringEnum(0) != (Enum?)ToEnum(0)); + //Assert.False((IntEnum?)ToIntEnum(0) != (StringEnum?)ToStringEnum(0)); + //Assert.False((StringEnum?)ToStringEnum(0) != (IntEnum?)ToIntEnum(0)); +#pragma warning restore xUnit2024 +#pragma warning restore CS8073 +#pragma warning restore IDE0004 +#pragma warning restore IDE0079 + } + + [Theory] + [InlineData(0, 0)] + [InlineData(0, 1)] + [InlineData(1, 0)] + public void CompareTo_WithEqualValues_ShouldHaveEqualityMatchingEquality(int one, int two) + { + Assert.Equal(one.Equals(two), ToEnum(one).CompareTo(ToEnum(two)) == 0); + Assert.Equal(one.Equals(two), ToIntEnum(one).CompareTo(ToIntEnum(two)) == 0); + Assert.Equal(one.Equals(two), ToStringEnum(one).CompareTo(ToStringEnum(two)) == 0); + + Assert.Equal(one.Equals(two), ToEnum(one).CompareTo(ToIntEnum(two)) == 0); + Assert.Equal(one.Equals(two), ToEnum(one).CompareTo(ToStringEnum(two)) == 0); + Assert.Equal(one.Equals(two), ToIntEnum(one).CompareTo(ToEnum(two)) == 0); + Assert.Equal(one.Equals(two), ToIntEnum(one).CompareTo(ToStringEnum(two)) == 0); + Assert.Equal(one.Equals(two), ToStringEnum(one).CompareTo(ToEnum(two)) == 0); + Assert.Equal(one.Equals(two), ToStringEnum(one).CompareTo(ToIntEnum(two)) == 0); + } + + [Theory] + [InlineData(null, null, 0)] + [InlineData(null, 0, -1)] + [InlineData(0, null, +1)] + [InlineData(0, 0, 0)] + [InlineData(0, 1, -1)] + [InlineData(1, 0, +1)] + public void CompareTo_Regularly_ShouldReturnExpectedResult(int? one, int? two, int expectedResult) + { + Assert.Equal(expectedResult, Comparer.Default.Compare(ToEnum(one), ToEnum(two))); + Assert.Equal(-expectedResult, Comparer.Default.Compare(ToEnum(two), ToEnum(one))); + Assert.Equal(expectedResult, Comparer.Default.Compare(ToIntEnum(one), ToIntEnum(two))); + Assert.Equal(-expectedResult, Comparer.Default.Compare(ToIntEnum(two), ToIntEnum(one))); + Assert.Equal(expectedResult, Comparer.Default.Compare(ToStringEnum(one), ToStringEnum(two))); + Assert.Equal(-expectedResult, Comparer.Default.Compare(ToStringEnum(two), ToStringEnum(one))); + + Assert.Equal(expectedResult, Comparer.Default.Compare(ToEnum(one), ToIntEnum(two))); + Assert.Equal(expectedResult, Comparer.Default.Compare(ToEnum(one), ToStringEnum(two))); + + Assert.Equal(expectedResult, Comparer.Default.Compare(ToIntEnum(one), ToEnum(two))); + Assert.Equal(expectedResult, Comparer.Default.Compare(ToStringEnum(one), ToEnum(two))); + } + + [Theory] + [InlineData(0, 0, 0)] + [InlineData(0, 1, -1)] + [InlineData(1, 0, +1)] + public void GreaterThan_Regularly_ShouldReturnExpectedResult(int one, int two, int expectedResult) + { + Assert.Equal(expectedResult > 0, ToEnum(one) > ToEnum(two)); + Assert.Equal(expectedResult > 0, ToEnum(one) > ToEnum(two).Value); + Assert.Equal(expectedResult > 0, ToEnum(one).Value > ToEnum(two)); + Assert.Equal(expectedResult <= 0, ToEnum(one) <= ToEnum(two)); + Assert.Equal(expectedResult <= 0, ToEnum(one) <= ToEnum(two).Value); + Assert.Equal(expectedResult <= 0, ToEnum(one).Value <= ToEnum(two)); + + Assert.Equal(expectedResult > 0, ToIntEnum(one) > ToIntEnum(two)); + Assert.Equal(expectedResult > 0, ToIntEnum(one) > ToIntEnum(two).Value); + Assert.Equal(expectedResult > 0, ToIntEnum(one).Value > ToIntEnum(two)); + Assert.Equal(expectedResult <= 0, ToIntEnum(one) <= ToIntEnum(two)); + Assert.Equal(expectedResult <= 0, ToIntEnum(one) <= ToIntEnum(two).Value); + Assert.Equal(expectedResult <= 0, ToIntEnum(one).Value <= ToIntEnum(two)); + + Assert.Equal(expectedResult > 0, ToStringEnum(one) > ToStringEnum(two)); + Assert.Equal(expectedResult > 0, ToStringEnum(one) > ToStringEnum(two).Value); + Assert.Equal(expectedResult > 0, ToStringEnum(one).Value > ToStringEnum(two)); + Assert.Equal(expectedResult <= 0, ToStringEnum(one) <= ToStringEnum(two)); + Assert.Equal(expectedResult <= 0, ToStringEnum(one) <= ToStringEnum(two).Value); + Assert.Equal(expectedResult <= 0, ToStringEnum(one).Value <= ToStringEnum(two)); + + Assert.Equal(expectedResult > 0, ToEnum(one) > ToIntEnum(two)); + Assert.Equal(expectedResult > 0, ToEnum(one) > ToStringEnum(two)); + Assert.Equal(expectedResult > 0, ToIntEnum(one) > ToEnum(two)); + Assert.Equal(expectedResult > 0, ToStringEnum(one) > ToEnum(two)); + + // Cannot compare directly between different primitive representations - can simply take their values for such comparisons + //Assert.Equal(expectedResult > 0, ToIntEnum(one) > ToStringEnum(two)); + //Assert.Equal(expectedResult > 0, ToStringEnum(one) > ToIntEnum(two)); + } + + [Theory] + [InlineData(0, 0, 0)] + [InlineData(0, 1, -1)] + [InlineData(1, 0, +1)] + public void LessThan_Regularly_ShouldReturnExpectedResult(int one, int two, int expectedResult) + { + Assert.Equal(expectedResult < 0, ToEnum(one) < ToEnum(two)); + Assert.Equal(expectedResult < 0, ToEnum(one) < ToEnum(two).Value); + Assert.Equal(expectedResult < 0, ToEnum(one).Value < ToEnum(two)); + Assert.Equal(expectedResult >= 0, ToEnum(one) >= ToEnum(two)); + Assert.Equal(expectedResult >= 0, ToEnum(one) >= ToEnum(two).Value); + Assert.Equal(expectedResult >= 0, ToEnum(one).Value >= ToEnum(two)); + + Assert.Equal(expectedResult < 0, ToIntEnum(one) < ToIntEnum(two)); + Assert.Equal(expectedResult < 0, ToIntEnum(one) < ToIntEnum(two).Value); + Assert.Equal(expectedResult < 0, ToIntEnum(one).Value < ToIntEnum(two)); + Assert.Equal(expectedResult >= 0, ToIntEnum(one) >= ToIntEnum(two)); + Assert.Equal(expectedResult >= 0, ToIntEnum(one) >= ToIntEnum(two).Value); + Assert.Equal(expectedResult >= 0, ToIntEnum(one).Value >= ToIntEnum(two)); + + Assert.Equal(expectedResult < 0, ToStringEnum(one) < ToStringEnum(two)); + Assert.Equal(expectedResult < 0, ToStringEnum(one) < ToStringEnum(two).Value); + Assert.Equal(expectedResult < 0, ToStringEnum(one).Value < ToStringEnum(two)); + Assert.Equal(expectedResult >= 0, ToStringEnum(one) >= ToStringEnum(two)); + Assert.Equal(expectedResult >= 0, ToStringEnum(one) >= ToStringEnum(two).Value); + Assert.Equal(expectedResult >= 0, ToStringEnum(one).Value >= ToStringEnum(two)); + + Assert.Equal(expectedResult < 0, ToEnum(one) < ToIntEnum(two)); + Assert.Equal(expectedResult < 0, ToEnum(one) < ToStringEnum(two)); + Assert.Equal(expectedResult < 0, ToIntEnum(one) < ToEnum(two)); + Assert.Equal(expectedResult < 0, ToStringEnum(one) < ToEnum(two)); + + // Cannot compare directly between different primitive representations - can simply take their values for such comparisons + //Assert.Equal(expectedResult < 0, ToIntEnum(one) < ToStringEnum(two)); + //Assert.Equal(expectedResult < 0, ToStringEnum(one) < ToIntEnum(two)); + } + + [Theory] + [InlineData(null, null)] + [InlineData(0, 0)] + [InlineData(1, 1)] + public void CastToUnderlyingType_Regularly_ShouldReturnExpectedResultOrThrowConfiguredException(int? value, int? expectedResult) + { + var intInstance = ToIntEnum(value); + var stringInstance = ToStringEnum(value); + + if (expectedResult is null) + { + // Configured exception + Assert.Throws(() => (int)(LazyThreadSafetyMode)intInstance!); + Assert.Throws(() => (int)(LazyThreadSafetyMode)stringInstance!); + } + else + { + Assert.Equal(expectedResult, (int)(LazyThreadSafetyMode)intInstance!); + Assert.Equal(expectedResult, (int)(LazyThreadSafetyMode)stringInstance!); + } + } + + [Theory] + [InlineData(null, null)] + [InlineData(0, 0)] + [InlineData(1, 1)] + public void CastToNullableUnderlyingType_Regularly_ShouldReturnExpectedResult(int? value, int? expectedResult) + { + var intInstance = ToIntEnum(value); + var stringInstance = ToStringEnum(value); + + Assert.Equal(expectedResult, (int?)(LazyThreadSafetyMode?)intInstance); + Assert.Equal(expectedResult, (int?)(LazyThreadSafetyMode?)stringInstance); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(1, 1)] + public void CastFromUnderlyingType_Regularly_ShouldReturnExpectedResult(int value, int expectedResult) + { + Assert.Equal(ToIntEnum(expectedResult), (IntEnum)(LazyThreadSafetyMode)value); + Assert.Equal(ToStringEnum(expectedResult), (StringEnum)(LazyThreadSafetyMode)value); + } + + [Theory] + [InlineData(null, null)] + [InlineData(0, 0)] + [InlineData(1, 1)] + public void CastFromNullableUnderlyingType_Regularly_ShouldReturnExpectedResult(int? value, int? expectedResult) + { + Assert.Equal((LazyThreadSafetyMode?)expectedResult, ((IntEnum?)(LazyThreadSafetyMode?)value)?.Value); + Assert.Equal((LazyThreadSafetyMode?)expectedResult, ((StringEnum?)(LazyThreadSafetyMode?)value)?.Value); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(1, 1)] + public void CastToNumericType_Regularly_ShouldReturnExpectedResult(int value, int expectedResult) + { + Assert.Equal(expectedResult, (Int128)ToIntEnum(value)); + Assert.Equal(expectedResult, (Int128)ToStringEnum(value)); + } + + [Theory] + [InlineData(null, null)] + [InlineData(0, 0)] + [InlineData(1, 1)] + public void CastToNullableCoreType_Regularly_ShouldReturnExpectedResult(int? value, int? expectedResult) + { + Assert.Equal(expectedResult, (Int128?)ToIntEnum(value)); + Assert.Equal(expectedResult, (Int128?)ToStringEnum(value)); + } + + [Fact] + public void CastBetweenWithAndWithoutPrimitive_Regularly_ShouldReturnExpectedResult() + { + Assert.Equal(ToEnum(1), (Enum)ToIntEnum(1)); + Assert.Equal(ToEnum(1), (Enum?)ToIntEnum(1)); + Assert.Equal(ToEnum(1), (Enum)ToStringEnum(1)); + Assert.Equal(ToEnum(1), (Enum?)ToStringEnum(1)); + + Assert.Equal(ToIntEnum(1), (IntEnum)ToEnum(1)); + Assert.Equal(ToIntEnum(1), (IntEnum?)ToEnum(1)); + Assert.Equal(ToStringEnum(1), (StringEnum)ToEnum(1)); + Assert.Equal(ToStringEnum(1), (StringEnum?)ToEnum(1)); + } + + [Theory] + [InlineData(0, "None")] + [InlineData(1, "PublicationOnly")] + public void Value_ViaCoreValueInterface_ShouldReturnExpectedResult(int value, string stringValue) + { + IntWrapper intInstance = ToIntEnum(value); + Assert.IsType(intInstance.Value); + Assert.Equal(value, intInstance.Value); + + StringWrapper stringInstance = ToStringEnum(value); + Assert.IsType(stringInstance.Value); + Assert.Equal(stringValue, stringInstance.Value); + } + + /// + /// Helper to access abstract statics. + /// + private static TWrapper CreateFromCoreValue(TValue value) + where TWrapper : ICoreValueWrapper + { + return TWrapper.Create(value); + } + + [Theory] + [InlineData(0, "None")] + [InlineData(1, "PublicationOnly")] + public void Create_ViaCoreValueInterface_ShouldReturnExpectedResult(int value, string stringValue) + { + Assert.IsType(CreateFromCoreValue(value)); + Assert.Equal((LazyThreadSafetyMode)value, CreateFromCoreValue(value).Value); + + Assert.IsType(CreateFromCoreValue(stringValue)); + Assert.Equal((LazyThreadSafetyMode)value, CreateFromCoreValue(value.ToString()).Value); + } + + [Theory] + [InlineData(0, "None")] + [InlineData(1, "PublicationOnly")] + public void Serialize_ToCoreType_ShouldReturnExpectedResult(int value, string stringValue) + { + IntWrapper intInstance = ToIntEnum(value); + Assert.IsType(intInstance.Serialize()); + Assert.Equal(value, intInstance.Serialize()); + + StringWrapper stringInstance = ToStringEnum(value); + Assert.IsType(stringInstance.Serialize()); + Assert.Equal(stringValue, stringInstance.Serialize()); + } + + [Theory] + [InlineData(null, null)] + [InlineData(0, "None")] + [InlineData(1, "PublicationOnly")] + public void SerializeWithSystemTextJson_Regularly_ShouldReturnExpectedResult(int? value, string? stringValue) + { + var intInstance = ToIntEnum(value); + Assert.Equal(value?.ToString() ?? "null", System.Text.Json.JsonSerializer.Serialize(intInstance)); + + var stringInstance = ToStringEnum(value); + Assert.Equal(value is null ? "null" : $@"""{stringValue}""", System.Text.Json.JsonSerializer.Serialize(stringInstance)); + } + + [Theory] + [InlineData(null, null)] + [InlineData(0, "None")] + [InlineData(1, "PublicationOnly")] + public void SerializeWithNewtonsoftJson_Regularly_ShouldReturnExpectedResult(int? value, string? stringValue) + { + var intInstance = ToIntEnum(value); + Assert.Equal(value?.ToString() ?? "null", Newtonsoft.Json.JsonConvert.SerializeObject(intInstance)); + + var stringInstance = ToStringEnum(value); + Assert.Equal(value is null ? "null" : $@"""{stringValue}""", Newtonsoft.Json.JsonConvert.SerializeObject(stringInstance)); + } + + /// + /// Helper to access abstract statics. + /// + private static TWrapper Deserialize(TValue value) + where TWrapper : IValueWrapper + { + return TWrapper.Deserialize(value); + } + + [Theory] + [InlineData(0, "None")] + [InlineData(1, "PublicationOnly")] + public void Deserialize_FromCoreType_ShouldReturnExpectedResult(int value, string stringValue) + { + Assert.IsType(Deserialize(value)); + Assert.Equal(value, (int)Deserialize(value).Value); + + Assert.IsType(Deserialize(stringValue)); + Assert.Equal(value, (int)Deserialize(stringValue).Value); + } + + /// + /// Deserialization bypasses validation. + /// + [Fact] + public void Deserialize_FromUndefinedCoreValue_ShouldReturnExpectedResult() + { + // From numeric we get that particular value, undefined though it may be + Assert.Equal((LazyThreadSafetyMode)(-1), Deserialize(-1).Value); + + // From string we get our default undefined value + Assert.Equal(~(LazyThreadSafetyMode)(1 | 2), Deserialize("Nonexistent").Value); + Assert.Equal((SimpleSByte)(-65), Deserialize, string>("Nonexistent").Value); // Preferred + Assert.Equal((AdvancedSByte)3, Deserialize, string>("Nonexistent").Value); // Found by scanning + Assert.Equal((SimpleShort)(-16397), Deserialize, string>("Nonexistent").Value); // Fallback when preferred not available + Assert.Equal((AdvancedShort)3, Deserialize, string>("Nonexistent").Value); // Found by scanning + } + + [Theory] + [InlineData("null", null, null)] + [InlineData("0", 0, "None")] + [InlineData("1", 1, "PublicationOnly")] + public void DeserializeWithSystemTextJson_Regularly_ShouldReturnExpectedResult(string json, int? value, string? stringValue) + { + Assert.Equal(value, (int?)System.Text.Json.JsonSerializer.Deserialize(json)?.Value); + + json = json == "null" ? json : $@"""{stringValue}"""; + Assert.Equal(value, (int?)System.Text.Json.JsonSerializer.Deserialize(json)?.Value); + } + + [Theory] + [InlineData("null", null, null)] + [InlineData("0", 0, "None")] + [InlineData("1", 1, "PublicationOnly")] + public void DeserializeWithNewtonsoftJson_Regularly_ShouldReturnExpectedResult(string json, int? value, string? stringValue) + { + if (value is not null) // To also test deserialization NOT wrapped in a nullable + Assert.Equal(value, (int)Newtonsoft.Json.JsonConvert.DeserializeObject(json).Value); + + json = json == "null" ? json : $@"""{stringValue}"""; + Assert.Equal(value, (int?)Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value); + } + + [Theory] + [InlineData(0, "None")] + [InlineData(1, "PublicationOnly")] + public void ReadAsPropertyNameWithSystemTextJson_Regularly_ShouldReturnExpectedResult(int value, string stringValue) + { + Assert.Equal(KeyValuePair.Create(ToIntEnum(value), true), System.Text.Json.JsonSerializer.Deserialize>($$"""{ "{{value}}": true }""")?.Single()); + + Assert.Equal(KeyValuePair.Create(ToStringEnum(value), true), System.Text.Json.JsonSerializer.Deserialize>($$"""{ "{{stringValue}}": true }""")?.Single()); + } + + [Theory] + [InlineData(0, "None")] + [InlineData(1, "PublicationOnly")] + public void WriteAsPropertyNameWithSystemTextJson_Regularly_ShouldReturnExpectedResult(int value, string stringValue) + { + Assert.Equal($$"""{"{{value}}":true}""", System.Text.Json.JsonSerializer.Serialize(new Dictionary() { [ToIntEnum(value)] = true })); + + Assert.Equal($$"""{"{{stringValue}}":true}""", System.Text.Json.JsonSerializer.Serialize(new Dictionary() { [ToStringEnum(value)] = true })); + } + + [Fact] + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [SuppressMessage("Design", "WrapperValueObjectDefaultExpression:Default expression instantiating unvalidated value object", Justification = "Needs to work, despite being bad practice.")] + public void FormattableToString_InAllScenarios_ShouldReturnExpectedResult() + { + Assert.Equal("None", default(Enum).ToString(format: null, formatProvider: null)); + Assert.Equal("None", default(IntEnum).ToString(format: null, formatProvider: null)); + Assert.Equal("None", default(StringEnum).ToString(format: null, formatProvider: null)); + Assert.Equal("PublicationOnly", ToEnum(1).ToString(format: null, formatProvider: null)); + Assert.Equal("PublicationOnly", ToIntEnum(1).ToString(format: null, formatProvider: null)); + Assert.Equal("PublicationOnly", ToStringEnum(1).ToString(format: null, formatProvider: null)); + + Assert.Equal("-1", DomainObjectSerializer.Deserialize(-1).ToString(format: null, formatProvider: null)); + } + + [Fact] + public void SpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResult() + { + Span result = stackalloc char[15]; + + Assert.True(ToEnum(1).TryFormat(result, out var charsWritten, format: null, provider: null)); + Assert.Equal(15, charsWritten); + Assert.Equal("PublicationOnly".AsSpan(), result); + + Assert.True(ToIntEnum(1).TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal(15, charsWritten); + Assert.Equal("PublicationOnly".AsSpan(), result); + + Assert.True(ToStringEnum(1).TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal(15, charsWritten); + Assert.Equal("PublicationOnly".AsSpan(), result); + + Assert.True(DomainObjectSerializer.Deserialize(-1).TryFormat(result, out charsWritten, format: null, provider: null)); + Assert.Equal("-1", result[..2]); + } + + [Fact] + public void UtfSpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResult() + { + Span result = stackalloc byte[15]; + + Assert.True(ToIntEnum(1).TryFormat(result, out var bytesWritten, format: null, provider: null)); + Assert.Equal(15, bytesWritten); + Assert.Equal("PublicationOnly"u8, result); + + Assert.True(ToEnum(1).TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal(15, bytesWritten); + Assert.Equal("PublicationOnly"u8, result); + + Assert.True(ToStringEnum(1).TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal(15, bytesWritten); + Assert.Equal("PublicationOnly"u8, result); + + Assert.True(DomainObjectSerializer.Deserialize(-1).TryFormat(result, out bytesWritten, format: null, provider: null)); + Assert.Equal("-1"u8, result[..2]); + } + + [Fact] + public void ParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() + { + var input = "publicationonly"; + + Assert.True(Enum.TryParse(input, provider: null, out var result1)); + Assert.Equal((LazyThreadSafetyMode)1, result1.Value); + Assert.Equal(result1, Enum.Parse(input, provider: null)); + + Assert.True(IntEnum.TryParse(input, provider: null, out var result2)); + Assert.Equal((LazyThreadSafetyMode)1, result2.Value); + Assert.Equal(result2, IntEnum.Parse(input, provider: null)); + + Assert.True(StringEnum.TryParse(input, provider: null, out var result3)); + Assert.Equal((LazyThreadSafetyMode)1, result3.Value); + Assert.Equal(result3, StringEnum.Parse(input, provider: null)); + } + + [Fact] + public void SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() + { + var input = "publicationonly".AsSpan(); + + Assert.True(Enum.TryParse(input, provider: null, out var result1)); + Assert.Equal((LazyThreadSafetyMode)1, result1.Value); + Assert.Equal(result1, Enum.Parse(input, provider: null)); + + Assert.True(IntEnum.TryParse(input, provider: null, out var result2)); + Assert.Equal((LazyThreadSafetyMode)1, result2.Value); + Assert.Equal(result2, IntEnum.Parse(input, provider: null)); + + Assert.True(StringEnum.TryParse(input, provider: null, out var result3)); + Assert.Equal((LazyThreadSafetyMode)1, result3.Value); + Assert.Equal(result3, StringEnum.Parse(input, provider: null)); + } + + [Fact] + public void Utf8SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() + { + var input = "publicationonly"u8; + + Assert.True(Enum.TryParse(input, provider: null, out var result1)); + Assert.Equal((LazyThreadSafetyMode)1, result1.Value); + Assert.Equal(result1, Enum.Parse(input, provider: null)); + + Assert.True(IntEnum.TryParse(input, provider: null, out var result2)); + Assert.Equal((LazyThreadSafetyMode)1, result2.Value); + Assert.Equal(result2, IntEnum.Parse(input, provider: null)); + + Assert.True(StringEnum.TryParse(input, provider: null, out var result3)); + Assert.Equal((LazyThreadSafetyMode)1, result3.Value); + Assert.Equal(result3, StringEnum.Parse(input, provider: null)); + } + + [Fact] + public void ParsabilityAndFormattability_InAllScenarios_ShouldBeGeneratedAccordingToTransitiveAvailability() + { + var interfaces = typeof(Enum).GetInterfaces(); + Assert.Contains(interfaces, interf => interf.Name == "ISpanFormattable"); + Assert.Contains(interfaces, interf => interf.Name == "ISpanParsable`1"); + Assert.Contains(interfaces, interf => interf.Name == "IUtf8SpanFormattable"); + Assert.Contains(interfaces, interf => interf.Name == "IUtf8SpanParsable`1"); + + interfaces = typeof(StringEnum).GetInterfaces(); + Assert.Contains(interfaces, interf => interf.Name == "ISpanFormattable"); + Assert.Contains(interfaces, interf => interf.Name == "ISpanParsable`1"); + Assert.Contains(interfaces, interf => interf.Name == "IUtf8SpanFormattable"); + Assert.Contains(interfaces, interf => interf.Name == "IUtf8SpanParsable`1"); + } +} diff --git a/DomainModeling.Tests/Enums/EnumExtensionsTests.cs b/DomainModeling.Tests/Enums/EnumExtensionsTests.cs new file mode 100644 index 0000000..4cb8700 --- /dev/null +++ b/DomainModeling.Tests/Enums/EnumExtensionsTests.cs @@ -0,0 +1,71 @@ +using System.Runtime.CompilerServices; +using Architect.DomainModeling.Enums; +using Xunit; + +namespace Architect.DomainModeling.Tests.Enums; + +public class EnumExtensionsTests +{ + private enum ByteEnum : byte + { + One = 1, + } + private enum LongEnum : long + { + MinusOne = -1, + Min = Int64.MinValue, + } + private enum UlongEnum : ulong + { + Max = UInt64.MaxValue, + } + + [Fact] + public void GetNumericValue_WithByte_ShouldReturnExpectedResult() + { + Assert.Equal((Int128)1, ByteEnum.One.GetNumericValue()); + } + + [Fact] + public void GetNumericValue_WithLong_ShouldReturnExpectedResult() + { + Assert.Equal((Int128)(-1), LongEnum.MinusOne.GetNumericValue()); + } + + [Fact] + public void GetNumericValue_WithUlong_ShouldReturnExpectedResult() + { + Assert.Equal((Int128)UInt64.MaxValue, UlongEnum.Max.GetNumericValue()); + } + + [Fact] + public void GetBinaryValue_WithByte_ShouldCastAndUnsafeConvertToUnderlyingTypeCorrectly() + { + var result = ByteEnum.One.GetBinaryValue(); + Assert.Equal(1, (byte)result); + Assert.Equal(1, Unsafe.As(ref result)); + Assert.Equal(ByteEnum.One, Unsafe.As(ref result)); + } + + [Fact] + public void GetBinaryValue_WithLong_ShouldCastAndUnsafeConvertToUnderlyingTypeCorrectly() + { + var result1 = LongEnum.MinusOne.GetBinaryValue(); + var result2 = LongEnum.Min.GetBinaryValue(); + + Assert.Equal(-1, (long)result1); + Assert.Equal(-1, Unsafe.As(ref result1)); + Assert.Equal(LongEnum.MinusOne, Unsafe.As(ref result1)); + + Assert.Equal(Int64.MinValue, (long)result2); + Assert.Equal(Int64.MinValue, Unsafe.As(ref result2)); + Assert.Equal(LongEnum.Min, Unsafe.As(ref result2)); + } + + [Fact] + public void GetBinaryValue_WithUlong_ShouldCastAndUnsafeConvertToUnderlyingTypeCorrectly() + { + var result = UlongEnum.Max.GetBinaryValue(); + Assert.Equal(UInt64.MaxValue, result); // Already the type we would cast to + } +} diff --git a/DomainModeling.Tests/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index cda3472..0e920b3 100644 --- a/DomainModeling.Tests/IdentityTests.cs +++ b/DomainModeling.Tests/IdentityTests.cs @@ -309,9 +309,6 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? on } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, "", 0, -1)] - [InlineData("", null, 0, +1)] [InlineData("", "", 0)] [InlineData("", "A", -1)] [InlineData("A", "", +1)] @@ -319,25 +316,16 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? on [InlineData("a", "A", +1)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void GreaterThan_WithString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult, int? expectedResultWithNullSensitiveComparison = null) + public void GreaterThan_WithString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult) { var left = (StringId)one; var right = (StringId)two; Assert.Equal(expectedResult > 0, left > right); Assert.Equal(expectedResult <= 0, left <= right); - - // Null StringIds are identical to empty-string StringIds - // However, that property does not hold when wrapped in a nullable, of course - expectedResult = expectedResultWithNullSensitiveComparison ?? expectedResult; - Assert.Equal(expectedResult > 0, (StringId?)one > (StringId?)two); - Assert.Equal(expectedResult <= 0, (StringId?)one <= (StringId?)two); } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, "", 0, -1)] - [InlineData("", null, 0, +1)] [InlineData("", "", 0)] [InlineData("", "A", -1)] [InlineData("A", "", +1)] @@ -345,63 +333,39 @@ public void GreaterThan_WithString_ShouldReturnExpectedResult(string? one, strin [InlineData("a", "A", +1)] [InlineData("A", "B", -1)] [InlineData("AA", "A", +1)] - public void LessThan_WithString_ShouldReturnExpectedResult(string? one, string? two, int expectedResult, int? expectedResultWithNullSensitiveComparison = null) + public void LessThan_WithString_ShouldReturnExpectedResult(string one, string two, int expectedResult) { var left = (StringId)one; var right = (StringId)two; Assert.Equal(expectedResult < 0, left < right); Assert.Equal(expectedResult >= 0, left >= right); - - // Null StringIds are identical to empty-string StringIds - // However, that property does not hold when wrapped in a nullable, of course - expectedResult = expectedResultWithNullSensitiveComparison ?? expectedResult; - Assert.Equal(expectedResult < 0, (StringId?)one < (StringId?)two); - Assert.Equal(expectedResult >= 0, (StringId?)one >= (StringId?)two); } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, 1, -1)] - [InlineData(1, null, +1)] [InlineData(1, 1, 0)] [InlineData(1, 2, -1)] [InlineData(2, 1, +1)] - public void GreaterThan_AsNullableOrNonNullableStruct_ShouldReturnExpectedResult(int? one, int? two, int expectedResult) + public void GreaterThan_Regularly_ShouldReturnExpectedResult(int one, int two, int expectedResult) { - var left = (DecimalId?)one; - var right = (DecimalId?)two; + var left = (DecimalId)one; + var right = (DecimalId)two; Assert.Equal(expectedResult > 0, left > right); Assert.Equal(expectedResult <= 0, left <= right); - - if (left is not null && right is not null) - { - Assert.Equal(left > right, (DecimalId)one! > (DecimalId)two!); - Assert.Equal(left <= right, (DecimalId)one! <= (DecimalId)two!); - } } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, 1, -1)] - [InlineData(1, null, +1)] [InlineData(1, 1, 0)] [InlineData(1, 2, -1)] [InlineData(2, 1, +1)] - public void LessThan_AsNullableOrNonNullableStruct_ShouldReturnExpectedResult(int? one, int? two, int expectedResult) + public void LessThan_Regularly_ShouldReturnExpectedResult(int one, int two, int expectedResult) { - var left = (DecimalId?)one; - var right = (DecimalId?)two; + var left = (DecimalId)one; + var right = (DecimalId)two; Assert.Equal(expectedResult < 0, left < right); Assert.Equal(expectedResult >= 0, left >= right); - - if (left is not null && right is not null) - { - Assert.Equal(left < right, (DecimalId)one! < (DecimalId)two!); - Assert.Equal(left >= right, (DecimalId)one! >= (DecimalId)two!); - } } [Theory] @@ -929,9 +893,9 @@ public void ParsabilityAndFormattability_InAllScenarios_ShouldBeGeneratedAccordi { var interfaces = typeof(FormatAndParseTestingUriWrapperId).GetInterfaces(); Assert.Contains(interfaces, interf => interf.Name == "ISpanFormattable"); - Assert.DoesNotContain(interfaces, interf => interf.Name == "ISpanParsable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "ISpanParsable`1"); Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanFormattable"); - Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanParsable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanParsable`1"); } } diff --git a/DomainModeling.Tests/WrapperValueObjectTests.cs b/DomainModeling.Tests/WrapperValueObjectTests.cs index dcf437f..23a92ac 100644 --- a/DomainModeling.Tests/WrapperValueObjectTests.cs +++ b/DomainModeling.Tests/WrapperValueObjectTests.cs @@ -36,7 +36,7 @@ public void Construct_WithNullReferenceType_ShouldThrow() [Fact] public void Construct_WithNullValueType_ShouldThrow() { - Assert.Throws(() => new IntValue(null)); + Assert.Throws(() => new IntValue(null!)); } [Fact] @@ -255,9 +255,6 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? on } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, "", -1)] - [InlineData("", null, +1)] [InlineData("", "", 0)] [InlineData("", "A", -1)] [InlineData("A", "", +1)] @@ -265,25 +262,16 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? on [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); - - if (left is not null && right is not null) - { - Assert.Equal(left > right, (StringValue)one! > (StringValue)two!); - Assert.Equal(left <= right, (StringValue)one! <= (StringValue)two!); - } } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, "", -1)] - [InlineData("", null, +1)] [InlineData("", "", 0)] [InlineData("", "A", -1)] [InlineData("A", "", +1)] @@ -291,63 +279,39 @@ public void GreaterThan_WithIgnoreCaseString_ShouldReturnExpectedResult(string? [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); - - if (left is not null && right is not null) - { - Assert.Equal(left < right, (StringValue)one! < (StringValue)two!); - Assert.Equal(left >= right, (StringValue)one! >= (StringValue)two!); - } } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, 1, -1)] - [InlineData(1, null, +1)] [InlineData(1, 1, 0)] [InlineData(1, 2, -1)] [InlineData(2, 1, +1)] - public void GreaterThan_AsNullableOrNonNullableStruct_ShouldReturnExpectedResult(int? one, int? two, int expectedResult) + public void GreaterThan_Regularly_ShouldReturnExpectedResult(int one, int two, int expectedResult) { - var left = (DecimalValue?)one; - var right = (DecimalValue?)two; + var left = (DecimalValue)one; + var right = (DecimalValue)two; Assert.Equal(expectedResult > 0, left > right); Assert.Equal(expectedResult <= 0, left <= right); - - if (left is not null && right is not null) - { - Assert.Equal(left > right, (DecimalValue)one! > (DecimalValue)two!); - Assert.Equal(left <= right, (DecimalValue)one! <= (DecimalValue)two!); - } } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, 1, -1)] - [InlineData(1, null, +1)] [InlineData(1, 1, 0)] [InlineData(1, 2, -1)] [InlineData(2, 1, +1)] - public void LessThan_AsNullableOrNonNullableStruct_ShouldReturnExpectedResult(int? one, int? two, int expectedResult) + public void LessThan_Regularly_ShouldReturnExpectedResult(int one, int two, int expectedResult) { - var left = (DecimalValue?)one; - var right = (DecimalValue?)two; + var left = (DecimalValue)one; + var right = (DecimalValue)two; Assert.Equal(expectedResult < 0, left < right); Assert.Equal(expectedResult >= 0, left >= right); - - if (left is not null && right is not null) - { - Assert.Equal(left < right, (DecimalValue)one! < (DecimalValue)two!); - Assert.Equal(left >= right, (DecimalValue)one! >= (DecimalValue)two!); - } } [Theory] @@ -873,9 +837,9 @@ public void ParsabilityAndFormattability_InAllScenarios_ShouldBeGeneratedAccordi { var interfaces = typeof(FormatAndParseTestingUriWrapper).GetInterfaces(); Assert.Contains(interfaces, interf => interf.Name == "ISpanFormattable"); - Assert.DoesNotContain(interfaces, interf => interf.Name == "ISpanParsable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "ISpanParsable`1"); Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanFormattable"); - Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanParsable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanParsable`1"); } } diff --git a/DomainModeling/Conversions/FormattingExtensions.cs b/DomainModeling/Conversions/FormattingExtensions.cs index dddd38e..46a4907 100644 --- a/DomainModeling/Conversions/FormattingExtensions.cs +++ b/DomainModeling/Conversions/FormattingExtensions.cs @@ -21,7 +21,7 @@ public static ReadOnlySpan Format(this T value, Span buffer, Read where T : notnull, ISpanFormattable { if (!value.TryFormat(buffer, out var charCount, format, provider)) - return value.ToString().AsSpan(); + return value.ToString(format.IsEmpty ? null : format.ToString(), provider).AsSpan(); return buffer[..charCount]; } diff --git a/DomainModeling/Conversions/ValueWrapperJsonConverter.cs b/DomainModeling/Conversions/ValueWrapperJsonConverter.cs index ace08d3..d2d0aa5 100644 --- a/DomainModeling/Conversions/ValueWrapperJsonConverter.cs +++ b/DomainModeling/Conversions/ValueWrapperJsonConverter.cs @@ -7,8 +7,8 @@ namespace Architect.DomainModeling.Conversions; /// A generic System.Text JSON converter for wrapper types, which serializes like the wrapped value itself. /// [UnconditionalSuppressMessage( - "Trimming", "IL2046", - Justification = "JsonConverter read/write methods are not marked with RequiresUnreferencedCode, but overrides require unreferenced code due to serialization." + "Trimming", "IL2046:All interface implementations and method overrides must have annotations matching the interface or overridden virtual method 'RequiresUnreferencedCodeAttribute' annotations", + Justification = "Unlike our base, we prefer to annotate the methods instead of the type, to avoid a warning merely because source-generated code includes a JSON converter." )] public sealed class ValueWrapperJsonConverter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, @@ -61,8 +61,8 @@ public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, /// It serializes to and from string. /// [UnconditionalSuppressMessage( - "Trimming", "IL2046", - Justification = "JsonConverter read/write methods are not marked with RequiresUnreferencedCode, but overrides require unreferenced code due to serialization." + "Trimming", "IL2046:All interface implementations and method overrides must have annotations matching the interface or overridden virtual method 'RequiresUnreferencedCodeAttribute' annotations", + Justification = "Unlike our base, we prefer to annotate the methods instead of the type, to avoid a warning merely because source-generated code includes a JSON converter." )] public sealed class LargeNumberValueWrapperJsonConverter< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 70dbb7c..d92a57e 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -34,8 +34,8 @@ Release notes: 4.0.0: -Platform support: -- BREAKING: Dropped support for .NET 6.0 and .NET 7.0 (EOL). +Platforms: +- BREAKING: Dropped support for .NET 6/7 (EOL). The base class is dead - long live the interface! - Feature: Generated Wrappers can now be structs. Unvalidated instances are avoided via analyzer warning against `default`. @@ -62,20 +62,21 @@ Performance: - Enhancement: Improved source generator performance. Misc: -- Semi-breaking: IFormattable & co for string wrappers have stopped treating null strings as "", as this could cover up mistakes instead of revealing them. +- Semi-breaking: IFormattable & co for string wrappers have stopped treating null strings as "", which covered up mistakes instead of revealing them. - Semi-breaking: IIdentity now implements IWrapperValueObject. +- Feature: DefinedEnum<TEnum> wrapper. - Feature: Non-generic Wrapper/Identity interfaces. -- Feature: DummyBuilder records clone themselves on each step, for reuse. -- Feature: Analyzer warns when '==' or similar operator implicitly casts some IValueObject to something else. This avoids accidentally comparing unrelated types. -- Fix: Fixed bug where source-generated records would always generate ToString()/Equals()/GetHashCode(), even if you wrote your own. +- Feature: DummyBuilder records clone on each step, for reuse. +- Feature: Analyzer warns when '==' or similar operator implicitly casts some IValueObject to something else. Avoids accidentally comparing unrelated types. +- Feature: Analyzer warns when '>' or similar operator risks unintended null handling. +- Fix: Fixed bug where source-generated records would ignore hand-written ToString()/Equals()/GetHashCode(). - Fix: Fixed bug where source-generated Wrappers/Identities would not recognize manual member implementations if they were explicit interface implementations. - Fix: Fixed bug where DummyBuilder generator struggled with nested types. - Fix: Fixed bug where "no source generation on nested type" warning would not show. -- Enhancement: Generated types now have the CompilerGeneratedAttribute. +- Enhancement: CompilerGeneratedAttribute throughout. - Enhancement: Entity.Id has private init. -- Enhancement: Nicer DebuggerDisplay of Wrappers/Identities. -- Enhancement: Generated struct Wrappers/Identities now generate NULLABLE comparison operators, to circumvent counterintuitive lifting behavior. -- Enhancement: Improved clarity of analyzer warnings, by stopping before subsequent problems occur. +- Enhancement: DebuggerDisplay for Wrappers/Identities. +- Enhancement: Analyzer warning clarity. - Enhancement: Improved correctness of trimming. The Architect diff --git a/DomainModeling/Enums/DefinedEnum.cs b/DomainModeling/Enums/DefinedEnum.cs new file mode 100644 index 0000000..006f1e6 --- /dev/null +++ b/DomainModeling/Enums/DefinedEnum.cs @@ -0,0 +1,608 @@ +using System.Buffers; +using System.Collections.Frozen; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Unicode; +using Architect.DomainModeling.Conversions; +using Architect.DomainModeling.Enums; + +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace Architect.DomainModeling; + +/// +/// Provides utilities and configuration for and . +/// +public static class DefinedEnum +{ + /// + /// + /// The factory used to produce an exception whenever a or is attempted to be constructed based on null. + /// + /// + /// Receives the enum type. + /// + /// + public static Func? ExceptionFactoryForNullInput { get; set; } + /// + /// + /// The factory used to produce an exception whenever a or is attempted to be constructed based on an undefined value for its type. + /// + /// + /// For an enum with the , any combination of bits used in its defined values is permitted, i.e. this is only used whenever a value includes any undefined bit. + /// + /// + /// Receives the enum type and the numeric value. + /// + /// + public static Func? ExceptionFactoryForUndefinedInput { get; set; } + + /// + /// Constructs a new , with type inference. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static DefinedEnum Create(TEnum enumValue) + where TEnum : unmanaged, Enum + { + return new DefinedEnum(enumValue); + } + + /// + /// + /// Constructs a new , with type inference. + /// + /// + /// Accepts a nullable parameter, but throws for null values. + /// For example, this is useful for a mandatory request input where omission must lead to rejection. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static DefinedEnum Create([DisallowNull] TEnum? enumValue) + where TEnum : unmanaged, Enum + { + return new DefinedEnum(enumValue); + } + + [DoesNotReturn] + internal static object ThrowNullInput(Type enumType, string paramName) + { + throw ExceptionFactoryForNullInput?.Invoke(enumType) ?? new ArgumentNullException(paramName); + } + + [DoesNotReturn] + internal static void ThrowUndefinedInput(Type enumType, Int128 numericValue) + { + throw ExceptionFactoryForUndefinedInput?.Invoke(enumType, numericValue) ?? new ArgumentException($"Only recognized {enumType.Name} values are permitted."); + } +} + +/// +/// +/// An enum of type whose value is one of the defined values for the enum type. +/// +/// +/// If has the , any combination of bits used in its defined values is permitted. +/// +/// +/// This type additionally improves the performance of certain operations, such as . +/// +/// +/// Values can be created with type inference using , or converted implicitly from a defined constant of . +/// +/// +/// An analyzer prevents wrapper value object structs like this from skipping validation via the 'default' keyword. +/// +/// +/// The type of the represented enum. +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +[DebuggerDisplay("{ToString()}")] +[WrapperValueObject] +public readonly struct DefinedEnum : + IWrapperValueObject, + IEquatable>, + IComparable>, + ISpanFormattable, + ISpanParsable>, + IUtf8SpanFormattable, + IUtf8SpanParsable>, + ICoreValueWrapper, TEnum> + where TEnum : unmanaged, Enum +{ + private static readonly FrozenDictionary DefinedValueNamePairs = Enum.GetValues() + .Distinct() // Multiple members may be defined with the same numeric value, but both value and Enum.GetName(value) will be identical between duplicates + .ToFrozenDictionary(value => value, value => Enum.GetName(value) ?? value.ToString()); + + private static readonly bool IsFlags = typeof(TEnum).IsDefined(typeof(FlagsAttribute), inherit: false); + private static readonly ulong AllFlags = DefinedValueNamePairs.Aggregate(0UL, (current, next) => current | next.Key.GetBinaryValue()); + + /// + /// + /// Contains an arbitrary undefined value for enum type . + /// + /// + /// The value may change between compilations. + /// + /// + /// The value is null in the rare case where defines every possible numeric value for its underlying type, such as with a byte-backed enum that defines all 256 possible numbers. + /// + /// + public static readonly TEnum? UndefinedValue = ~AllFlags is var unusedBits && Unsafe.As(ref unusedBits) is var value && value.GetBinaryValue() != 0UL // Any bits unused? + ? value + : !IsFlags && EnumExtensions.TryGetUndefinedValue(out value) // With all bits used, for non-flags we can still look for an individual unused value, since values are not combined + ? value + : null; + + public TEnum Value + { + get => this._value; + internal init => this._value = value; + } + private readonly TEnum _value; + + /// + /// Throws the same exception as when a was attempted to be constructed with an undefined input value. + /// + [DoesNotReturn] + public static TEnum ThrowUndefinedInput(TEnum value = default) + { + DefinedEnum.ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue()); + return default; + } + + public DefinedEnum(TEnum value) + { + if (IsFlags) + { + if ((AllFlags | value.GetBinaryValue()) != AllFlags) + DefinedEnum.ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue()); + } + else + { + if (!DefinedValueNamePairs.ContainsKey(value)) + DefinedEnum.ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue()); + } + + this.Value = value; + } + + /// + /// Accepts a nullable parameter, but throws for null values. + /// For example, this is useful for a mandatory request input where omission must lead to rejection. + /// + public DefinedEnum([DisallowNull] TEnum? value) + : this(value ?? (TEnum)DefinedEnum.ThrowNullInput(typeof(TEnum), nameof(value))) + { + } + + /// + /// Obsolete: This constructor exists for deserialization purposes only. + /// + [Obsolete("This constructor exists for deserialization purposes only.")] + public DefinedEnum() + { + } + + public override string ToString() + { + return DefinedValueNamePairs.GetValueOrDefault(this.Value) ?? + this.Value.ToString(); // For combined flags, or in case someone created an undefined enum after all, such as using default(T) + } + + public string ToString(string? format) + { + return format is null + ? this.ToString() // Efficient + : this.Value.ToString(format); + } + + public override int GetHashCode() + { + return this.Value.GetHashCode(); + } + + public override bool Equals(object? other) + { + return other is DefinedEnum otherValue && this.Equals(otherValue); + } + + public bool Equals(DefinedEnum other) + { + return EqualityComparer.Default.Equals(this.Value, other.Value); + } + + public int CompareTo(DefinedEnum other) + { + return Comparer.Default.Compare(this.Value, other.Value); + } + + public static bool operator ==(DefinedEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left.Value, right.Value); + public static bool operator !=(DefinedEnum left, DefinedEnum right) => !(left == right); + public static bool operator ==(DefinedEnum left, TEnum right) => EqualityComparer.Default.Equals(left.Value, right); + public static bool operator !=(DefinedEnum left, TEnum right) => !(left == right); + public static bool operator ==(TEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left, right.Value); + public static bool operator !=(TEnum left, DefinedEnum right) => !(left == right); + + public static bool operator >(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) > 0; + public static bool operator <=(DefinedEnum left, DefinedEnum right) => !(left > right); + public static bool operator >(DefinedEnum left, TEnum right) => Comparer.Default.Compare(left.Value, right) > 0; + public static bool operator <=(DefinedEnum left, TEnum right) => !(left > right); + public static bool operator >(TEnum left, DefinedEnum right) => Comparer.Default.Compare(left, right.Value) > 0; + public static bool operator <=(TEnum left, DefinedEnum right) => !(left > right); + + public static bool operator <(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) < 0; + public static bool operator >=(DefinedEnum left, DefinedEnum right) => !(left < right); + public static bool operator <(DefinedEnum left, TEnum right) => Comparer.Default.Compare(left.Value, right) < 0; + public static bool operator >=(DefinedEnum left, TEnum right) => !(left < right); + public static bool operator <(TEnum left, DefinedEnum right) => Comparer.Default.Compare(left, right.Value) < 0; + public static bool operator >=(TEnum left, DefinedEnum right) => !(left < right); + + /// + /// Constrained to defined constants by analyzer. + /// + public static implicit operator DefinedEnum(TEnum value) => new DefinedEnum(value); + public static implicit operator TEnum(DefinedEnum instance) => instance.Value; + + /// + /// Constrained to defined constants by analyzer. + /// + [return: NotNullIfNotNull(nameof(value))] + public static implicit operator DefinedEnum?(TEnum? value) => value is { } actual ? new DefinedEnum(actual) : (DefinedEnum?)null; + [return: NotNullIfNotNull(nameof(instance))] + public static implicit operator TEnum?(DefinedEnum? instance) => instance?.Value; + + public static explicit operator Int128(DefinedEnum instance) => instance.Value.GetNumericValue(); + + [return: NotNullIfNotNull(nameof(instance))] + public static explicit operator Int128?(DefinedEnum? instance) => instance?.Value.GetNumericValue(); + + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; + + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; + + #region Wrapping & Serialization + + static DefinedEnum IValueWrapper, TEnum>.Create(TEnum value) + { + return new DefinedEnum(value); + } + + TEnum IValueWrapper, TEnum>.Serialize() + { + return this.Value; + } + + static DefinedEnum IValueWrapper, TEnum>.Deserialize(TEnum value) + { + return default(DefinedEnum) with { Value = value }; + } + + #endregion + + #region Formatting & Parsing + + /// Obsolete and ignored for enums. + public string ToString(string? format, IFormatProvider? formatProvider) => + this.ToString(format); + + /// Obsolete and ignored for enums. + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => + format.IsEmpty && this.ToString() is { } value // Efficient + ? (charsWritten = value.TryCopyTo(destination) ? value.Length : 0) != 0 + : Enum.TryFormat(this.Value, destination, out charsWritten, format); + + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => + Enum.TryParse(s, ignoreCase: true, out var value) + ? (result = (DefinedEnum)value) is var _ + : !((result = default) is var _); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => + Enum.TryParse(s, ignoreCase: true, out var value) + ? (result = (DefinedEnum)value) is var _ + : !((result = default) is var _); + + public static DefinedEnum Parse(string s, IFormatProvider? provider) => + (DefinedEnum)Enum.Parse(s, ignoreCase: true); + + public static DefinedEnum Parse(ReadOnlySpan s, IFormatProvider? provider) => + (DefinedEnum)Enum.Parse(s, ignoreCase: true); + + /// Obsolete and ignored for enums. + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) => + format.IsEmpty && this.ToString() is { } value // Efficient + ? Utf8.FromUtf16(value, utf8Destination, charsRead: out _, bytesWritten: out bytesWritten) == OperationStatus.Done + : Utf8.FromUtf16(this.Format(stackalloc char[64], format, provider), utf8Destination, charsRead: out _, bytesWritten: out bytesWritten) == OperationStatus.Done; // Delegates to char overload + + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => + utf8Text.Length <= 2048 && stackalloc char[utf8Text.Length] is var chars && Utf8.ToUtf16(utf8Text, chars, bytesRead: out _, charsWritten: out var charsWritten) == OperationStatus.Done + ? TryParse(chars[..charsWritten], provider, out result) + : !((result = default) is var _); + + public static DefinedEnum Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => + utf8Text.Length <= 2048 && stackalloc char[utf8Text.Length] is var chars && Utf8.ToUtf16(utf8Text, chars, bytesRead: out _, charsWritten: out var charsWritten) == OperationStatus.Done + ? Parse(chars[..charsWritten], provider) + : Parse(Encoding.UTF8.GetString(utf8Text), provider); // Throws the appropriate exception + + #endregion +} + +/// +/// +/// An enum of type whose value is one of the defined values for the enum type. +/// +/// +/// If has the , any combination of bits used in its defined values is permitted. +/// +/// +/// The second type parameter indicates whether the enum should be represented as or numerically in contexts where the enum itself cannot be used. +/// Examples include JSON serialization and Entity Framework mapping. +/// +/// +/// This type additionally improves the performance of certain operations, such as . +/// +/// +/// Values can be created with type inference using , or converted implicitly from a defined constant of . +/// They can also be received as parameters of type , without the need to repeat the type parameter for the primitive. +/// +/// +/// An analyzer prevents wrapper value object structs like this from skipping validation via the 'default' keyword. +/// +/// +/// The type of the represented enum. +/// The underlying representation, either or the enum's exact underlying integral type. +[SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] +[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", + Justification = "We want serialization configured irrespectve of whether the developer uses trimming.")] +[System.Text.Json.Serialization.JsonConverter(typeof(EnumJsonConverterFactory))] +[Newtonsoft.Json.JsonConverter(typeof(EnumNewtonsoftJsonConverterFactory))] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +[DebuggerDisplay("{ToString()}")] +[WrapperValueObject] +public readonly struct DefinedEnum : + IWrapperValueObject, + IEquatable>, IEquatable>, + IComparable>, IComparable>, + ISpanFormattable, + ISpanParsable>, + IUtf8SpanFormattable, + IUtf8SpanParsable>, + ICoreValueWrapper, TPrimitive> + where TEnum : unmanaged, Enum + where TPrimitive : IEquatable, IComparable, ISpanParsable, IConvertible // string or TEnumUnderlying +{ + public TEnum Value + { + get => this._value; + internal init => this._value = value; + } + private readonly TEnum _value; + + static DefinedEnum() + { + if (typeof(TPrimitive) != typeof(string) && typeof(TPrimitive) != Enum.GetUnderlyingType(typeof(TEnum))) + throw new NotSupportedException($"{nameof(DefinedEnum)}<{nameof(TEnum)}, {nameof(TPrimitive)}> requires {nameof(TPrimitive)} to be either string or the enum's underlying integer type."); + } + + public DefinedEnum(TEnum value) + { + this.Value = new DefinedEnum(value).Value; + } + + /// + /// Accepts a nullable parameter, but throws for null values. + /// For example, this is useful for a mandatory request input where omission must lead to rejection. + /// + public DefinedEnum([DisallowNull] TEnum? value) + : this(value ?? (TEnum)DefinedEnum.ThrowNullInput(typeof(TEnum), nameof(value))) + { + } + + /// + /// Obsolete: This constructor exists for deserialization purposes only. + /// + [Obsolete("This constructor exists for deserialization purposes only.")] + public DefinedEnum() + { + } + + public override string ToString() + { + return (default(DefinedEnum) with { Value = this.Value }).ToString(); + } + + public string ToString(string? format) + { + return (default(DefinedEnum) with { Value = this.Value }).ToString(format); + } + + public override int GetHashCode() + { + return this.Value.GetHashCode(); + } + + public override bool Equals(object? other) + { + return (other is DefinedEnum otherValue && this.Equals(otherValue)) || + (other is DefinedEnum genericOtherValue && this.Equals(genericOtherValue)); + } + + public bool Equals(DefinedEnum other) + { + return EqualityComparer.Default.Equals(this.Value, other.Value); + } + + public bool Equals(DefinedEnum other) + { + return EqualityComparer.Default.Equals(this.Value, other.Value); + } + + public int CompareTo(DefinedEnum other) + { + return Comparer.Default.Compare(this.Value, other.Value); + } + + public int CompareTo(DefinedEnum other) + { + return Comparer.Default.Compare(this.Value, other.Value); + } + + public static bool operator ==(DefinedEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left.Value, right.Value); + public static bool operator !=(DefinedEnum left, DefinedEnum right) => !(left == right); + public static bool operator ==(DefinedEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left.Value, right.Value); + public static bool operator !=(DefinedEnum left, DefinedEnum right) => !(left == right); + public static bool operator ==(DefinedEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left.Value, right.Value); + public static bool operator !=(DefinedEnum left, DefinedEnum right) => !(left == right); + public static bool operator ==(DefinedEnum left, TEnum right) => EqualityComparer.Default.Equals(left.Value, right); + public static bool operator !=(DefinedEnum left, TEnum right) => !(left == right); + public static bool operator ==(TEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left, right.Value); + public static bool operator !=(TEnum left, DefinedEnum right) => !(left == right); + + public static bool operator >(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) > 0; + public static bool operator <=(DefinedEnum left, DefinedEnum right) => !(left > right); + public static bool operator >(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) > 0; + public static bool operator <=(DefinedEnum left, DefinedEnum right) => !(left > right); + public static bool operator >(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) > 0; + public static bool operator <=(DefinedEnum left, DefinedEnum right) => !(left > right); + public static bool operator >(DefinedEnum left, TEnum right) => Comparer.Default.Compare(left.Value, right) > 0; + public static bool operator <=(DefinedEnum left, TEnum right) => !(left > right); + public static bool operator >(TEnum left, DefinedEnum right) => Comparer.Default.Compare(left, right.Value) > 0; + public static bool operator <=(TEnum left, DefinedEnum right) => !(left > right); + + public static bool operator <(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) < 0; + public static bool operator >=(DefinedEnum left, DefinedEnum right) => !(left < right); + public static bool operator <(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) < 0; + public static bool operator >=(DefinedEnum left, DefinedEnum right) => !(left < right); + public static bool operator <(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) < 0; + public static bool operator >=(DefinedEnum left, DefinedEnum right) => !(left < right); + public static bool operator <(DefinedEnum left, TEnum right) => Comparer.Default.Compare(left.Value, right) < 0; + public static bool operator >=(DefinedEnum left, TEnum right) => !(left < right); + public static bool operator <(TEnum left, DefinedEnum right) => Comparer.Default.Compare(left, right.Value) < 0; + public static bool operator >=(TEnum left, DefinedEnum right) => !(left < right); + + /// + /// Constrained to defined constants by analyzer. + /// + public static implicit operator DefinedEnum(TEnum value) => new DefinedEnum(value); + public static implicit operator TEnum(DefinedEnum instance) => instance.Value; + + /// + /// Constrained to defined constants by analyzer. + /// + [return: NotNullIfNotNull(nameof(value))] + public static implicit operator DefinedEnum?(TEnum? value) => value is { } actual ? new DefinedEnum(actual) : (DefinedEnum?)null; + [return: NotNullIfNotNull(nameof(instance))] + public static implicit operator TEnum?(DefinedEnum? instance) => instance?.Value; + + public static explicit operator Int128(DefinedEnum instance) => instance.Value.GetNumericValue(); + + [return: NotNullIfNotNull(nameof(instance))] + public static explicit operator Int128?(DefinedEnum? instance) => instance?.Value.GetNumericValue(); + + #region Wrapping & Serialization + + TPrimitive IValueWrapper, TPrimitive>.Value => typeof(TPrimitive) == typeof(string) + ? (TPrimitive)(object)this.ToString() // Efficient + : Unsafe.As(ref Unsafe.AsRef(in this._value)); + + static DefinedEnum IValueWrapper, TPrimitive>.Create(TPrimitive value) + { + return typeof(TPrimitive) == typeof(string) + ? Parse((string)(object)value, provider: null) + : new DefinedEnum(Unsafe.As(ref value)); + } + + TPrimitive IValueWrapper, TPrimitive>.Serialize() + { + return typeof(TPrimitive) == typeof(string) + ? (TPrimitive)(object)this.ToString() // Efficient + : Unsafe.As(ref Unsafe.AsRef(in this._value)); + } + + static DefinedEnum IValueWrapper, TPrimitive>.Deserialize(TPrimitive value) + { + return default(DefinedEnum) with + { + Value = typeof(TPrimitive) == typeof(string) + ? TryParse((string)(object)value, provider: null, out var result) ? result : (DefinedEnum.UndefinedValue ?? default) + : Unsafe.As(ref value), + }; + } + + #endregion + + #region Formatting & Parsing + + /// Obsolete and ignored for enums. + public string ToString(string? format, IFormatProvider? formatProvider) => + (default(DefinedEnum) with { Value = this.Value }).ToString(format, formatProvider); + + /// Obsolete and ignored for enums. + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => + (default(DefinedEnum) with { Value = this.Value }).TryFormat(destination, out charsWritten, format, provider); + + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => + DefinedEnum.TryParse(s, provider, out var intermediateResult) + ? (result = default(DefinedEnum) with { Value = intermediateResult.Value }) is var _ + : !((result = default) is var _); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => + DefinedEnum.TryParse(s, provider, out var intermediateResult) + ? (result = default(DefinedEnum) with { Value = intermediateResult.Value }) is var _ + : !((result = default) is var _); + + public static DefinedEnum Parse(string s, IFormatProvider? provider) => + default(DefinedEnum) with { Value = DefinedEnum.Parse(s, provider).Value }; + + public static DefinedEnum Parse(ReadOnlySpan s, IFormatProvider? provider) => + default(DefinedEnum) with { Value = DefinedEnum.Parse(s, provider).Value }; + + /// Obsolete and ignored for enums. + public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) => + (default(DefinedEnum) with { Value = this.Value }).TryFormat(utf8Destination, out bytesWritten, format, provider); + + public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => + DefinedEnum.TryParse(utf8Text, provider, out var intermediateResult) + ? (result = default(DefinedEnum) with { Value = intermediateResult.Value }) is var _ + : !((result = default) is var _); + + public static DefinedEnum Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => + default(DefinedEnum) with { Value = DefinedEnum.Parse(utf8Text, provider).Value }; + + #endregion +} diff --git a/DomainModeling/Enums/EnumExtensions.cs b/DomainModeling/Enums/EnumExtensions.cs new file mode 100644 index 0000000..b7a75d9 --- /dev/null +++ b/DomainModeling/Enums/EnumExtensions.cs @@ -0,0 +1,136 @@ +using System.Runtime.CompilerServices; + +namespace Architect.DomainModeling.Enums; + +internal static class EnumExtensions +{ + private static readonly byte DefaultUndefinedValue = 191; // Greatest prime under 3/4 of Byte.MaxValue + private static readonly ushort FallbackUndefinedValue = 49139; // Greatest prime under 3/4 of UInt16.MaxValue + + /// + /// + /// Attempts to return one of a small set of predefined values if one is undefined for . + /// + /// + /// Does not accounts for the . + /// + /// + public static bool TryGetUndefinedValueFast(out TEnum value) + where TEnum : unmanaged, Enum + { + var defaultUndefined = Unsafe.As(ref Unsafe.AsRef(in DefaultUndefinedValue)); + if (!Enum.IsDefined(defaultUndefined)) + { + value = defaultUndefined; + return true; + } + + var fallbackUndefined = Unsafe.As(ref Unsafe.AsRef(in FallbackUndefinedValue)); + if (Unsafe.SizeOf() >= 2 && !Enum.IsDefined(fallbackUndefined)) + { + value = fallbackUndefined; + return true; + } + + value = default; + return false; + } + + /// + /// + /// Attempts to find an undefined value for . + /// + /// + /// Does not accounts for the . + /// + /// + public static bool TryGetUndefinedValue(out TEnum value) + where TEnum : unmanaged, Enum + { + if (TryGetUndefinedValueFast(out value)) + return true; + + var values = Enum.GetValues(); + System.Diagnostics.Debug.Assert(values.Select(GetBinaryValue).Order().SequenceEqual(values.Select(GetBinaryValue)), "Enum.GetValues() was expected to return elements in binary order."); + + // If we do not end with the binary maximum, then use that + var enumBinaryMax = ~0UL >> (64 - 8 * Unsafe.SizeOf()); // E.g. 64-0 bits for ulong/long, 64-32 for uint/int, and so on + if (values.Length == 0 || values[^1].GetBinaryValue() < enumBinaryMax) + { + value = Unsafe.As(ref enumBinaryMax); + return true; + } + + // If we do not start with the default, then use that + ulong previousValue; + if ((previousValue = values[0].GetBinaryValue()) != 0UL) + { + value = default; + return true; + } + + foreach (var definedValue in values.Skip(1)) + { + // If there is a gap between the current and previous item + var currentValue = definedValue.GetBinaryValue(); + if (currentValue > previousValue + 1) + { + previousValue++; + value = Unsafe.As(ref previousValue); + return true; + } + previousValue = currentValue; + } + + value = default; + return false; + } + + /// + /// Returns the numeric value of the given . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Int128 GetNumericValue(this T enumValue) + where T : unmanaged, Enum + { + // Optimized by JIT, as Type.GetTypeCode(T) is treated as a constant + return Type.GetTypeCode(typeof(T)) switch + { + TypeCode.Byte => (Int128)Unsafe.As(ref enumValue), + TypeCode.SByte => (Int128)Unsafe.As(ref enumValue), + TypeCode.Int16 => (Int128)Unsafe.As(ref enumValue), + TypeCode.UInt16 => (Int128)Unsafe.As(ref enumValue), + TypeCode.Int32 => (Int128)Unsafe.As(ref enumValue), + TypeCode.UInt32 => (Int128)Unsafe.As(ref enumValue), + TypeCode.Int64 => (Int128)Unsafe.As(ref enumValue), + TypeCode.UInt64 => (Int128)Unsafe.As(ref enumValue), + _ => default, + }; + } + + /// + /// + /// Returns the binary value of the given , contained in a . + /// + /// + /// The original value's bytes can be retrieved by doing a cast or to the original enum or underlying type. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong GetBinaryValue(this T enumValue) + where T : unmanaged, Enum + { + var result = 0UL; + + // Since the actual value may be smaller than ulong's 8 bytes, we must align to the least significant byte + // This way, casting the ulong back to the original type gets back the exact original bytes + // On little-endian, that means aligning to the left of the bytes + // On big-endian, that means aligning to the right of the bytes + if (BitConverter.IsLittleEndian) + Unsafe.WriteUnaligned(ref Unsafe.As(ref result), enumValue); + else + Unsafe.WriteUnaligned(ref Unsafe.Add(ref Unsafe.As(ref result), sizeof(ulong) - Unsafe.SizeOf()), enumValue); + + return result; + } +} diff --git a/DomainModeling/Enums/EnumJsonConverter.cs b/DomainModeling/Enums/EnumJsonConverter.cs new file mode 100644 index 0000000..64c5074 --- /dev/null +++ b/DomainModeling/Enums/EnumJsonConverter.cs @@ -0,0 +1,91 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Architect.DomainModeling.Conversions; + +namespace Architect.DomainModeling.Enums; + +/// +/// A factory to produce a generic System.Text JSON converter for enum wrapper types, which serializes like the wrapped value itself. +/// +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +[UnconditionalSuppressMessage( + "Trimming", "IL2046:All interface implementations and method overrides must have annotations matching the interface or overridden virtual method 'RequiresUnreferencedCodeAttribute' annotations", + Justification = "Unlike our base, we prefer to annotate the methods instead of the type, to avoid a warning merely because source-generated code includes a JSON converter." +)] +internal sealed class EnumJsonConverterFactory : JsonConverterFactory +{ + private const string RequiresUnreferencedCodeMessage = "Serialization requires unreferenced code."; + private const string RequiresDynamicCodeMessage = "Serialization requires dynamic code."; + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsConstructedGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(DefinedEnum<,>); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + [UnconditionalSuppressMessage( + "Trimming", "IL2055:Either the type on which the MakeGenericType is called can't be statically determined, or the type parameters to be used for generic arguments can't be statically determined", + Justification = "Both the converted and converter types are marked with DynamicallyAccessedMemberTypes.All." + )] + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return Activator.CreateInstance(typeof(EnumJsonConverter<,>).MakeGenericType(typeToConvert, typeToConvert.GenericTypeArguments[1])) as JsonConverter; + } +} + +/// +/// A generic System.Text JSON converter for enum wrapper types, which serializes like the wrapped value itself. +/// +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +[UnconditionalSuppressMessage( + "Trimming", "IL2046:All interface implementations and method overrides must have annotations matching the interface or overridden virtual method 'RequiresUnreferencedCodeAttribute' annotations", + Justification = "Unlike our base, we prefer to annotate the methods instead of the type, to avoid a warning merely because source-generated code includes a JSON converter." +)] +internal sealed class EnumJsonConverter< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, + TPrimitive> + : JsonConverter + where TWrapper : IValueWrapper, ISpanParsable +{ + private const string RequiresUnreferencedCodeMessage = "Serialization requires unreferenced code."; + private const string RequiresDynamicCodeMessage = "Serialization requires dynamic code."; + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override TWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = JsonSerializer.Deserialize(ref reader, options)!; + return DomainObjectSerializer.Deserialize(value); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override void Write(Utf8JsonWriter writer, TWrapper value, JsonSerializerOptions options) + { + var serializedValue = DomainObjectSerializer.Serialize(value); + JsonSerializer.Serialize(writer, serializedValue, options); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override TWrapper ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = ((JsonConverter)options.GetConverter(typeof(TPrimitive))).ReadAsPropertyName(ref reader, typeToConvert, options)!; + return DomainObjectSerializer.Deserialize(value); + } + + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + public override void WriteAsPropertyName(Utf8JsonWriter writer, TWrapper value, JsonSerializerOptions options) + { + var serializedValue = DomainObjectSerializer.Serialize(value)!; + ((JsonConverter)options.GetConverter(typeof(TPrimitive))).WriteAsPropertyName( + writer, + serializedValue, + options); + } +} diff --git a/DomainModeling/Enums/EnumNewtonsoftJsonConverter.cs b/DomainModeling/Enums/EnumNewtonsoftJsonConverter.cs new file mode 100644 index 0000000..4e19484 --- /dev/null +++ b/DomainModeling/Enums/EnumNewtonsoftJsonConverter.cs @@ -0,0 +1,76 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Architect.DomainModeling.Conversions; +using Newtonsoft.Json; + +namespace Architect.DomainModeling.Enums; + +/// +/// A factory to produce a generic Newtonsoft JSON converter for enum wrapper types, which serializes like the wrapped value itself. +/// +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +internal sealed class EnumNewtonsoftJsonConverterFactory : JsonConverter +{ + private static readonly ConcurrentDictionary ConvertersPerType = new(concurrencyLevel: 1, capacity: 11); + + public override bool CanConvert(Type objectType) + { + return objectType.IsConstructedGenericType && objectType.GetGenericTypeDefinition() == typeof(DefinedEnum<,>); + } + + [UnconditionalSuppressMessage( + "Trimming", "IL2055:Either the type on which the MakeGenericType is called can't be statically determined, or the type parameters to be used for generic arguments can't be statically determined", + Justification = "Both the converted and converter types are marked with DynamicallyAccessedMemberTypes.All." + )] + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + var nullableUnderlyingType = Nullable.GetUnderlyingType(objectType); + if (nullableUnderlyingType is not null) + { + if (reader.Value is null) + return null; + objectType = nullableUnderlyingType; + } + + return ConvertersPerType.GetOrAdd(objectType, type => (JsonConverter)Activator.CreateInstance(typeof(EnumNewtonsoftJsonConverter<,>).MakeGenericType(type, type.GenericTypeArguments[1]))!) + .ReadJson(reader, objectType, existingValue, serializer); + } + + [UnconditionalSuppressMessage( + "Trimming", "IL2055:Either the type on which the MakeGenericType is called can't be statically determined, or the type parameters to be used for generic arguments can't be statically determined", + Justification = "Both the converted and converter types are marked with DynamicallyAccessedMemberTypes.All." + )] + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value?.GetType() is { } type) + ConvertersPerType.GetOrAdd(type, type => (JsonConverter)Activator.CreateInstance(typeof(EnumNewtonsoftJsonConverter<,>).MakeGenericType(type, type.GenericTypeArguments[1]))!) + .WriteJson(writer, value, serializer); + else + serializer.Serialize(writer, null); + } +} + +/// +/// A generic Newtonsoft JSON converter for enum wrapper types, which serializes like the wrapped value itself. +/// +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +internal sealed class EnumNewtonsoftJsonConverter< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, + TPrimitive> + : JsonConverter + where TWrapper : IValueWrapper, ISpanParsable +{ + public override TWrapper? ReadJson(JsonReader reader, Type objectType, TWrapper? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var value = serializer.Deserialize(reader); + return DomainObjectSerializer.Deserialize(value); + } + + public override void WriteJson(JsonWriter writer, TWrapper? value, JsonSerializer serializer) + { + var underlyingValue = value is not TWrapper instance + ? (object?)null + : DomainObjectSerializer.Serialize(instance); + serializer.Serialize(writer, underlyingValue); + } +} diff --git a/README.md b/README.md index b7c2c73..0242ccb 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ A value object is an an immutable data model representing one or more values. Su Consider the following type: ```cs -public class Color : ValueObject +public class Color { public ushort Red { get; private init; } public ushort Green { get; private init; } @@ -46,20 +46,23 @@ This is the non-boilerplate portion of the value object, i.e. everything that we - The `IEquatable` interface implementation. - Operator overloads for `==` and `!=` based on `Equals()`, since a value object only ever cares about its contents, never its reference identity. - Potentially the `IComparable` interface implementation. +- Potentially operator overloads for `>`, `<`, `>=`, and `<=` based on `CompareTo()`. - Correctly configured nullable reference types (`?` vs. no `?`) on all mentioned boilerplate code. +- The 'sealed' keyword. - Unit tests on any _hand-written_ boilerplate code. +Records help with some of the above, but not all. Even worse, they pretend to implement structural equality, but fail to do so for collection types. + Change the type as follows to have source generators tackle all of the above and more: ```cs [ValueObject] -public partial class Color +public partial record 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. @@ -78,7 +81,7 @@ The wrapper value object is just another value object. Its existence is merely a Consider the following type: ```cs -public class Description : WrapperValueObject +public class Description { protected override StringComparison StringComparison => StringComparison.Ordinal; @@ -101,22 +104,123 @@ Besides all the things that the value object in the previous section was missing - 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"}`. +- If the underlying type is parsable and/or formattable, formatting and parsing methods. +- Ideally, JSON converters that convert instances to and from `"MyDescription"` rather than `{"Value":"MyDescription"}`, and _without_ re-running validation. (Existing domain models are trusted. Never deserialize a domain model from an untrusted source. Use separate contract DTOs for that.) +- If Entity Framework is used, mappings to and from `string`. Change the type as follows to have source generators tackle all of the above and more: ```cs [WrapperValueObject] -public partial class Description +public partial record 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. +#### Structs + +Wrapper values objects are allowed to be structs. +In fact, this can even be advisable. It reduces heap allocations (and thus garbage collection pressure), and it prevents the need for null checks when such objects are passed to constructors and methods. + +Structs always have a default constructor, and they can also be created via the `default` keyword. +To prevent the creation of unvalidated instances, the default constructor for struct wrapper value objects is marked as obsolete, and an included analyzer warns against the use of the `default` keyword for such types. + +Structs are usually the way to go. If shenanigans are expected, such as the use of a generic method to produce unvalidated values, then classes can be used to enforce the constructor validation more thoroughly. + +#### Enums + +Special wrapper value objects for enums are provided out-of-the-box. They help avoid the following common boilerplate code: + +- Checking `Enum.IsDefined(value)` when it is injected into a constructor or method. +- Manually mapping to string or the enum's underlying integer type with Entity Framework. +- Configuring how the enum should be JSON-serialized. + +Instead, we can do the following: + +```cs +public class MyEntity +{ + // Maps and serializes to string automatically + public DefinedEnum Kind { get; private set; } + + // Maps and serializes to int automatically + public DefinedEnum StatusCode { get; private set; } + + public MyEntity( + // No need to repeat string/int here + DefinedEnum kind, + DefinedEnum statusCode) + { + // No need to check if the values are defined + this.Kind = kind; + this.StatusCode = statusCode; + } +} +``` + +`DefinedEnum` can serve as a property or field, which warrants specifying the underlying representation. +`DefinedEnum` is a simplification intended for passing values around. An analyzer prevents the latter from being stored in a field or property. + +The validation is performed whenever a `DefinedEnum` or `DefinedEnum` is constructed from a primitive. +By default, `ArgumentException` is thrown for an input other than a defined value for the enum, and `ArgumentNullException` for a null input. +However, you can customize the exceptions globally: + +```cs +public class Program +{ + [ModuleInitializer] + internal static void InitializeModule() + { + DefinedEnum.ExceptionFactoryForNullInput = (Type enumType) => + throw new NullValidationException($"{enumType.Name} expects a non-null value."); + DefinedEnum.ExceptionFactoryForUndefinedInput = (Type enumType, Int128 value) => + throw new ValidationException($"Only recognized {enumType.Name} values are permitted."); + } +} +``` + +Type inference and implicit conversions make it easy to produce values: + +```cs +public DefinedEnum Kind { get; private set; } + +public void AdjustKind(DefinedEnum kind) +{ + this.Kind = kind; +} + +public void DemonstrateUsage(Kind someInputThatMightBeUndefined) +{ + // From a constant that represents a defined value: + this.Kind = Kind.Regular; // Valid + this.AdjustKind(Kind.Regular); // Valid + + // Otherwise: + this.Kind = someInputThatMightBeUndefined; // Compiler error + this.Kind = (Kind)(-1); // Compiler error + this.AdjustKind((Kind)(-1)); // Compiler error + this.Kind = DefinedEnum.Create(Kind.Regular); // Valid + this.AdjustKind(DefinedEnum.Create(Kind.Regular)); // Valid +} +``` + +When writing a mapper to map from a DTO to a domain object, enums can be converted like this: + +```cs +public static DefinedEnum ToDomain(KindDto dto) +{ + return new DefinedEnum(dto switch + { + KindDto.A => Kind.A, + KindDto.B => Kind.B, + _ => DefinedEnum.ThrowUndefinedInput(), // Or: DefinedEnum.UndefinedValue!.Value, to have the constructor throw the same thing + }); +} +``` + ### 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. @@ -175,27 +279,17 @@ For a more database-friendly alternative to UUIDs, see [Distributed IDs](https:/ ### Identity -Identity types are a special case of value objects. Unlike other value objects, they are perfectly suitable to be implemented as structs: +Identity types are a special case of wrapper value object, with some noteworthy characteristics: -- The enforced default constructor is unproblematic, because there is hardly such a thing as an invalid ID value. Although ID 0 or -1 might not _exist_, the same might be true for ID 999999, which would still be valid as a value. +- An ID tends to lack the need for constructor validation. +- The default constructor is unproblematic, because there is hardly such a thing as an invalid ID value. Although ID 0 or -1 might not _exist_, the same might be true for ID 999999, which would still be valid as a value. - The possibility of an ID variable containing `null` is often undesirable. Structs avoid this complication. (Where we _want_ nullability, a nullable struct can be used, e.g. `PaymentId?`. -- If the underlying type is `string`, the generator ensures that its `Value` property returns the empty string instead of `null`. This way, even `string`-wrapping identities know only one "empty" value and avoid representing `null`. - -Since an application is expected to work with many ID instances, using structs for them is a nice optimization that reduces heap allocations. +- If the underlying type is `string`, the generator ensures that its `Value` property returns the empty string instead of `null`. This way, even `string`-wrapping identities know only one "empty" value and avoid ever representing `null` as anything special. Source-generated identities implement both `IEquatable` and `IComparable` automatically. They are declared as follows: ```cs -[Identity] -public readonly partial struct PaymentId : IIdentity -{ -} -``` - -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 -[Identity] +[IdentityValueObject] public partial record struct ExternalId; ``` @@ -205,14 +299,14 @@ Note that an [entity](#entity) has the option of having its own ID type generate 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: +To mark domain event types as such, irrespective 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. +Besides providing consistency, such a marker attribute can enable miscellaneous concerns. For example, if this package's [Entity Framework conventions](#entity-framework-conventions) are used, domain events can be included. ### DummyBuilder @@ -273,7 +367,6 @@ This way, whenever a constructor is changed, the only test code that breaks is t As the builder is repaired to account for the changed constructor, all tests work again. If a new constructor parameter was added, existing tests tend to work perfectly fine as long as the builder provides a sensible default value for the parameter. Unfortunately, the dummy builders tend to consist of boilerplate code and can be tedious to write and maintain. - Change the type as follows to get source generation for it: ```cs @@ -317,26 +410,26 @@ Any type that inherits from `ValueObject` also gains access to a set of (highly ### 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. +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. Functionally, 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, 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. +- Each generated `IIdentity` and `IWrapperValueObject` applies a JSON converter for both System.Text.Json and Newtonsoft.Json, each of which deserialize without the use of (parameterized) constructors. +- Each generated regular `IValueObject` 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 conventions](#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. +To ingest data and/or expose data outside of the bounded context, create separate contracts, with mappers to convert back and forth. +It is advisable to write such mappers manually, so that a compiler error occurs when changes to either end would break the mapping. -Serialization inside the bounded context is useful, such as for persistence, be it in the form of JSON documents or in relational database tables. +Serialization of domain objects _within_ 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. +The generated JSON converters and Entity Framework conventions (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. @@ -360,7 +453,7 @@ At the time of writing, Entity Framework's `ComplexProperty()` does [not yet](ht 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. +If the generated [Entity Framework conventions](#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 @@ -368,6 +461,8 @@ Conventions to provide Entity Framework mappings are generated on-demand, only i 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. +The features described in this section work with Entity Framework Core 7+, although active testing and maintenance are done against the latest version. + ```cs internal sealed class MyDbContext : DbContext { From 3b46673d0ec873e4a2863926d78bda8012622671 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 24 Sep 2025 18:15:18 +0200 Subject: [PATCH 21/23] Revert "Made Entity.Id private init." This reverts commit 8d46796ad5a9384470d73f6365fce6dfd0426cbc. --- DomainModeling/DomainModeling.csproj | 1 - DomainModeling/Entity.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index d92a57e..a341f96 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -74,7 +74,6 @@ Misc: - Fix: Fixed bug where DummyBuilder generator struggled with nested types. - Fix: Fixed bug where "no source generation on nested type" warning would not show. - Enhancement: CompilerGeneratedAttribute throughout. -- Enhancement: Entity.Id has private init. - Enhancement: DebuggerDisplay for Wrappers/Identities. - Enhancement: Analyzer warning clarity. - Enhancement: Improved correctness of trimming. diff --git a/DomainModeling/Entity.cs b/DomainModeling/Entity.cs index 8b0640f..143617a 100644 --- a/DomainModeling/Entity.cs +++ b/DomainModeling/Entity.cs @@ -81,7 +81,7 @@ public abstract class Entity< /// /// The entity's unique identity. /// - public TId Id { get; private init; } + public TId Id { get; } /// The unique identity for the entity. protected Entity(TId id) From b50f618135f00ee6a44e1b9c2c552834fbfc2e69 Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Tue, 30 Sep 2025 21:09:35 +0200 Subject: [PATCH 22/23] Replaced DefinedEnums by enum validation. --- ...finedEnumMemberWithoutPrimitiveAnalyzer.cs | 86 -- ...edEnumConversionFromNonConstantAnalyzer.cs | 109 --- ...UnvalidatedEnumMemberAssignmentAnalyzer.cs | 180 ++++ ...dEnumFieldWithoutPrimitiveTypeCodeFixer.cs | 100 --- .../MissingStringComparisonCodeFixProvider.cs | 37 +- .../SyntaxNodeExtensions.cs | 35 + ...nvalidatedEnumMemberAssignmentCodeFixer.cs | 112 +++ .../EntityFrameworkConfigurationGenerator.cs | 48 +- ...idatedEnumMemberAssignmentAnalyzerTests.cs | 107 +++ ...iftingOnComparisonOperatorAnalyzerTests.cs | 3 - ...lueObjectDefaultExpressionAnalyzerTests.cs | 5 - .../Comparisons/EnumerableComparerTests.cs | 2 +- ...ityFrameworkConfigurationGeneratorTests.cs | 29 +- .../Enums/DefinedEnumTests.cs | 778 +----------------- ...ests.cs => InternalEnumExtensionsTests.cs} | 2 +- DomainModeling/DomainModeling.csproj | 2 +- DomainModeling/Enums/DefinedEnum.cs | 593 +------------ DomainModeling/Enums/EnumExtensions.cs | 146 ++-- DomainModeling/Enums/EnumJsonConverter.cs | 91 -- .../Enums/EnumNewtonsoftJsonConverter.cs | 76 -- .../Enums/InternalEnumExtensions.cs | 136 +++ README.md | 101 +-- 22 files changed, 730 insertions(+), 2048 deletions(-) delete mode 100644 DomainModeling.Analyzer/Analyzers/DefinedEnumMemberWithoutPrimitiveAnalyzer.cs delete mode 100644 DomainModeling.Analyzer/Analyzers/ImplicitDefinedEnumConversionFromNonConstantAnalyzer.cs create mode 100644 DomainModeling.Analyzer/Analyzers/UnvalidatedEnumMemberAssignmentAnalyzer.cs delete mode 100644 DomainModeling.CodeFixProviders/DefinedEnumFieldWithoutPrimitiveTypeCodeFixer.cs create mode 100644 DomainModeling.CodeFixProviders/SyntaxNodeExtensions.cs create mode 100644 DomainModeling.CodeFixProviders/UnvalidatedEnumMemberAssignmentCodeFixer.cs create mode 100644 DomainModeling.Tests/Analyzers/UnvalidatedEnumMemberAssignmentAnalyzerTests.cs rename DomainModeling.Tests/Enums/{EnumExtensionsTests.cs => InternalEnumExtensionsTests.cs} (97%) delete mode 100644 DomainModeling/Enums/EnumJsonConverter.cs delete mode 100644 DomainModeling/Enums/EnumNewtonsoftJsonConverter.cs create mode 100644 DomainModeling/Enums/InternalEnumExtensions.cs diff --git a/DomainModeling.Analyzer/Analyzers/DefinedEnumMemberWithoutPrimitiveAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/DefinedEnumMemberWithoutPrimitiveAnalyzer.cs deleted file mode 100644 index a169f36..0000000 --- a/DomainModeling.Analyzer/Analyzers/DefinedEnumMemberWithoutPrimitiveAnalyzer.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Diagnostics; - -namespace Architect.DomainModeling.Analyzer.Analyzers; - -/// -/// Enforces the use of DefinedEnum<TEnum, TPrimitive> over DefinedEnum<TEnum> in properties and fields. -/// -[DiagnosticAnalyzer(LanguageNames.CSharp)] -public sealed class DefinedEnumMemberWithoutPrimitiveAnalyzer : DiagnosticAnalyzer -{ - [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] - private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( - id: "DefinedEnumMemberMissingPrimitiveSpecification", - title: "DefinedEnum member missing specification of primitive representation", - messageFormat: "DefinedEnum member {0}.{1} must specify its primitive representation using the second generic type parameter", - category: "Design", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); - - public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; - - public override void Initialize(AnalysisContext context) - { - context.EnableConcurrentExecution(); - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics); - - context.RegisterSyntaxNodeAction(AnalyzePropertyDeclaration, SyntaxKind.PropertyDeclaration); - context.RegisterSyntaxNodeAction(AnalyzeFieldDeclaration, SyntaxKind.FieldDeclaration); - } - - private static void AnalyzePropertyDeclaration(SyntaxNodeAnalysisContext context) - { - var propertySyntax = (PropertyDeclarationSyntax)context.Node; - var property = context.SemanticModel.GetDeclaredSymbol(propertySyntax); - - if (property is null) - return; - - WarnAgainstMissingPrimitiveSpecification(context, property.Type, property.ContainingType, propertySyntax.Type); - } - - private static void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context) - { - var fieldSyntax = (FieldDeclarationSyntax)context.Node; - - // Note that fields can be defined like this: - // private int field1, field2, field3; - if (fieldSyntax.Declaration.Variables.Count == 0) // Prevents a NullReferenceException when enumerating the variables - return; - foreach (var fieldVariableSyntax in fieldSyntax.Declaration.Variables) - { - if (context.SemanticModel.GetDeclaredSymbol(fieldVariableSyntax) is not IFieldSymbol field) - continue; - - WarnAgainstMissingPrimitiveSpecification(context, field.Type, field.ContainingType, fieldSyntax.Declaration.Type); - } - } - - private static void WarnAgainstMissingPrimitiveSpecification(SyntaxNodeAnalysisContext context, ITypeSymbol typeSymbol, ITypeSymbol containingType, SyntaxNode locationNode) - { - // Dig through nullable - if (typeSymbol.IsNullable(out var nullableUnderlyingType)) - typeSymbol = nullableUnderlyingType; - - if (typeSymbol is not - INamedTypeSymbol - { - IsGenericType: true, Arity: 1, Name: "DefinedEnum", - ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } - }) - return; - - var diagnostic = Diagnostic.Create( - DiagnosticDescriptor, - locationNode.GetLocation(), - containingType.Name, - typeSymbol.Name); - - context.ReportDiagnostic(diagnostic); - } -} diff --git a/DomainModeling.Analyzer/Analyzers/ImplicitDefinedEnumConversionFromNonConstantAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/ImplicitDefinedEnumConversionFromNonConstantAnalyzer.cs deleted file mode 100644 index b60d382..0000000 --- a/DomainModeling.Analyzer/Analyzers/ImplicitDefinedEnumConversionFromNonConstantAnalyzer.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.Operations; - -namespace Architect.DomainModeling.Analyzer.Analyzers; - -/// -/// Prevents attempts to implicilty convert from a non-constant TEnum value to DefinedEnum<TEnum> or DefinedEnum<TEnum, TPrimitive>. -/// -[DiagnosticAnalyzer(LanguageNames.CSharp)] -public sealed class ImplicitDefinedEnumConversionFromNonConstantAnalyzer : DiagnosticAnalyzer -{ - [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] - private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( - id: "ImplicitConversionFromUnvalidatedEnumToDefinedEnum", - title: "Implicit conversion from unvalidated enum to DefinedEnum", - messageFormat: "Only a defined enum constant may be implicitly converted to DefinedEnum. For non-constant values, use DefinedEnum.Create(), a constructor, or an explicit conversion.", - category: "Usage", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true); - - public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; - - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics); - context.EnableConcurrentExecution(); - - context.RegisterOperationAction(AnalyzeConversion, OperationKind.Conversion); - } - - private static void AnalyzeConversion(OperationAnalysisContext context) - { - var conversion = (IConversionOperation)context.Operation; - - // Only implicit conversions are relevant to us - if (!conversion.IsImplicit) - return; - - var from = conversion.Operand.Type; - var to = conversion.Type; - - // Dig through nullables - if (from.IsNullable(out var nullableUnderlyingType)) - from = nullableUnderlyingType; - if (to.IsNullable(out nullableUnderlyingType)) - to = nullableUnderlyingType; - - // Only from enum is relevant to us - if (from is not { TypeKind: TypeKind.Enum } enumType) - return; - - // Only to DefinedEnum is relevant to us - if (to is not - INamedTypeSymbol - { - IsGenericType: true, Arity: 1 or 2, Name: "DefinedEnum", - ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } - }) - return; - - // Produce an error if the implicit conversion is not coming from a defined constant value of the enum's type - if (!IsDefinedEnumConstant(enumType, conversion.Operand.ConstantValue)) - { - var diagnostic = Diagnostic.Create( - DiagnosticDescriptor, - conversion.Syntax.GetLocation()); - - context.ReportDiagnostic(diagnostic); - } - } - - private static bool IsDefinedEnumConstant(ITypeSymbol enumType, Optional constantValue) - { - if (!constantValue.HasValue) - return false; - - if (enumType is not INamedTypeSymbol { EnumUnderlyingType: { } } namedEnumType) - return false; - - var binaryValue = GetBinaryValue(namedEnumType.EnumUnderlyingType, constantValue.Value); - - var valueIsDefined = namedEnumType.GetMembers().Any(member => - member is IFieldSymbol { ConstantValue: var value } && GetBinaryValue(namedEnumType.EnumUnderlyingType, value) == binaryValue); - - return valueIsDefined; - } - - private static ulong? GetBinaryValue(ITypeSymbol enumUnderlyingType, object? value) - { - if (value is null) - return null; - - return (enumUnderlyingType.SpecialType, Type.GetTypeCode(value.GetType())) switch - { - (SpecialType.System_Byte, TypeCode.Byte) => Convert.ToByte(value), - (SpecialType.System_SByte, TypeCode.SByte) => (ulong)Convert.ToSByte(value), - (SpecialType.System_UInt16, TypeCode.UInt16) => Convert.ToUInt16(value), - (SpecialType.System_Int16, TypeCode.Int16) => (ulong)Convert.ToInt16(value), - (SpecialType.System_UInt32, TypeCode.UInt32) => Convert.ToUInt32(value), - (SpecialType.System_Int32, TypeCode.Int32) => (ulong)Convert.ToInt32(value), - (SpecialType.System_UInt64, TypeCode.UInt64) => Convert.ToUInt64(value), - (SpecialType.System_Int64, TypeCode.Int64) => (ulong)Convert.ToInt64(value), - _ => null, - }; - } -} diff --git a/DomainModeling.Analyzer/Analyzers/UnvalidatedEnumMemberAssignmentAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/UnvalidatedEnumMemberAssignmentAnalyzer.cs new file mode 100644 index 0000000..1afa9c5 --- /dev/null +++ b/DomainModeling.Analyzer/Analyzers/UnvalidatedEnumMemberAssignmentAnalyzer.cs @@ -0,0 +1,180 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Architect.DomainModeling.Analyzer.Analyzers; + +/// +/// Prevents assignment of unvalidated enum values to members of an IDomainObject. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UnvalidatedEnumMemberAssignmentAnalyzer : DiagnosticAnalyzer +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] + private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( + id: "UnvalidatedEnumAssignmentToDomainobject", + title: "Unvalidated enum assignment to domain object member", + messageFormat: "The assigned value was not validated. Use the AsDefined(), AsDefinedFlags(), or AsUnvalidated() extension methods to specify the intent.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + + context.RegisterOperationAction(AnalyzeAssignment, + OperationKind.SimpleAssignment, + OperationKind.CoalesceAssignment); + } + + private static void AnalyzeAssignment(OperationAnalysisContext context) + { + var assignment = (IAssignmentOperation)context.Operation; + + if (assignment.Target is not IMemberReferenceOperation memberRef) + return; + + if (assignment.Value.Type is not { } assignedValueType) + return; + + // Dig through nullable + if (assignedValueType.IsNullable(out var nullableUnderlyingType)) + assignedValueType = nullableUnderlyingType; + + var memberType = memberRef.Type.IsNullable(out var memberNullableUnderlyingType) ? memberNullableUnderlyingType : memberRef.Type; + if (memberType is not { TypeKind: TypeKind.Enum } enumType) + return; + + // Only if target member is a member of some IDomainObject + if (!memberRef.Member.ContainingType.AllInterfaces.Any(interf => + interf is { Name: "IDomainObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } })) + return; + + // Flag each possible assigned value that is not either validated through one of the extension methods or an acceptable constant + var locations = EnumerateUnvalidatedValues(assignment.Value, memberRef, enumType) + .Select(operation => operation.Syntax.GetLocation()); + + foreach (var location in locations) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptor, + location); + + context.ReportDiagnostic(diagnostic); + } + } + + private static IEnumerable EnumerateUnvalidatedValues(IOperation operation, IMemberReferenceOperation member, ITypeSymbol enumType) + { + // Dig through up to two conversions + var operationWithoutConversion = operation switch + { + IConversionOperation { Operand: IConversionOperation conversion } => conversion.Operand, + IConversionOperation conversion => conversion.Operand, + _ => operation, + }; + + // Recurse into the arms of ternaries and switch expressions + if (operationWithoutConversion is IConditionalOperation conditional) + { + foreach (var result in EnumerateUnvalidatedValues(conditional.WhenTrue, member, enumType)) + yield return result; + foreach (var result in conditional.WhenFalse is null ? [] : EnumerateUnvalidatedValues(conditional.WhenFalse, member, enumType)) + yield return result; + yield break; + } + if (operationWithoutConversion is ISwitchExpressionOperation switchExpression) + { + foreach (var arm in switchExpression.Arms) + foreach (var result in EnumerateUnvalidatedValues(arm.Value, member, enumType)) + yield return result; + yield break; + } + + // Ignore throw expressions + if (operationWithoutConversion is IThrowOperation) + yield break; + + // Ignore if validated by AsDefined() or the like + if (IsValidatedWithExtensionMethod(operationWithoutConversion)) + yield break; + + var constantValue = operation.ConstantValue; + + // Dig through up to two conversions + if (operation is IConversionOperation conversionOperation) + { + if (!constantValue.HasValue && conversionOperation.Operand.ConstantValue.HasValue) + constantValue = conversionOperation.Operand.ConstantValue.Value; + + if (conversionOperation.Operand is IConversionOperation nestedConversionOperation) + { + if (!constantValue.HasValue && nestedConversionOperation.Operand.ConstantValue.HasValue) + constantValue = nestedConversionOperation.Operand.ConstantValue.Value; + } + } + + // Ignore if assigning null or a defined constant + if (constantValue.HasValue && (constantValue.Value is null || IsDefinedEnumConstantOrNullableThereof(enumType, constantValue.Value))) + yield break; + + // Ignore if assigning default(T?) (i.e. null) or default (i.e. null) to a nullable member + // Note: We need to use the "operation" var directly to correctly evaluate the conversions + if (operation is IDefaultValueOperation or IConversionOperation { Operand: IDefaultValueOperation { ConstantValue.HasValue: false } } && member.Type.IsNullable(out _)) + yield break; + + yield return operation; + } + + private static bool IsValidatedWithExtensionMethod(IOperation operation) + { + if (operation is not IInvocationOperation invocation) + return false; + + var method = invocation.TargetMethod; + method = method.ReducedFrom ?? method; // value.AsDefined() vs. EnumExtensions.AsDefined() + + if (method.Name is not "AsDefined" and not "AsDefinedFlags" and not "AsUnvalidated") + return false; + + if (method.ContainingType is not { Name: "EnumExtensions", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } }) + return false; + + return true; + } + + private static bool IsDefinedEnumConstantOrNullableThereof(ITypeSymbol enumType, object constantValue) + { + if (enumType is not INamedTypeSymbol { EnumUnderlyingType: { } } namedEnumType) + return false; + + var binaryValue = GetBinaryValue(namedEnumType.EnumUnderlyingType, constantValue); + + var valueIsDefined = namedEnumType.GetMembers().Any(member => + member is IFieldSymbol { ConstantValue: { } value } && GetBinaryValue(namedEnumType.EnumUnderlyingType, value) == binaryValue); + + return valueIsDefined; + } + + private static ulong? GetBinaryValue(ITypeSymbol enumUnderlyingType, object value) + { + return (enumUnderlyingType.SpecialType, Type.GetTypeCode(value.GetType())) switch + { + (SpecialType.System_Byte, TypeCode.Byte) => Convert.ToByte(value), + (SpecialType.System_SByte, TypeCode.SByte) => (ulong)Convert.ToSByte(value), + (SpecialType.System_UInt16, TypeCode.UInt16) => Convert.ToUInt16(value), + (SpecialType.System_Int16, TypeCode.Int16) => (ulong)Convert.ToInt16(value), + (SpecialType.System_UInt32, TypeCode.UInt32) => Convert.ToUInt32(value), + (SpecialType.System_Int32, TypeCode.Int32) => (ulong)Convert.ToInt32(value), + (SpecialType.System_UInt64, TypeCode.UInt64) => Convert.ToUInt64(value), + (SpecialType.System_Int64, TypeCode.Int64) => (ulong)Convert.ToInt64(value), + _ => null, + }; + } +} diff --git a/DomainModeling.CodeFixProviders/DefinedEnumFieldWithoutPrimitiveTypeCodeFixer.cs b/DomainModeling.CodeFixProviders/DefinedEnumFieldWithoutPrimitiveTypeCodeFixer.cs deleted file mode 100644 index 667f235..0000000 --- a/DomainModeling.CodeFixProviders/DefinedEnumFieldWithoutPrimitiveTypeCodeFixer.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Collections.Immutable; -using System.Composition; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CodeActions; -using Microsoft.CodeAnalysis.CodeFixes; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace Architect.DomainModeling.CodeFixProviders; - -[Shared] -[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(DefinedEnumFieldWithoutPrimitiveTypeCodeFixer))] -public sealed class DefinedEnumFieldWithoutPrimitiveTypeCodeFixer : CodeFixProvider -{ - private static readonly ImmutableArray FixableDiagnosticIdConstant = ["DefinedEnumMemberMissingPrimitiveSpecification"]; - - public override ImmutableArray FixableDiagnosticIds => FixableDiagnosticIdConstant; - - public override FixAllProvider? GetFixAllProvider() - { - return null; - } - - public override async Task RegisterCodeFixesAsync(CodeFixContext context) - { - var diagnostic = context.Diagnostics.First(diagnostic => diagnostic.Id == FixableDiagnosticIdConstant[0]); - var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); - if (root is null) return; - - var diagnosticSpan = diagnostic.Location.SourceSpan; - var node = root.FindNode(diagnosticSpan); - - var memberTypeSyntax = node.FirstAncestorOrSelf()?.Type ?? - node.FirstAncestorOrSelf()?.Declaration.Type; - if (memberTypeSyntax is null) return; - - var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); - if (semanticModel is null) return; - - var typeInfo = semanticModel.GetTypeInfo(memberTypeSyntax, context.CancellationToken); - if (typeInfo.Type is not INamedTypeSymbol namedType) return; - - // Dig through nullable - var genericSyntaxToReplace = memberTypeSyntax as GenericNameSyntax; - if (namedType is INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } nullableType && nullableType.TypeArguments[0] is INamedTypeSymbol nullableUnderlyingType && - memberTypeSyntax is NullableTypeSyntax { ElementType: GenericNameSyntax nestedGenericSyntax }) - { - namedType = nullableUnderlyingType; - genericSyntaxToReplace = nestedGenericSyntax; - } - if (genericSyntaxToReplace is null) return; - - if (namedType.ConstructedFrom is not { Name: "DefinedEnum", Arity: 1, ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } }) - return; - - if (namedType.TypeArguments[0] is not INamedTypeSymbol { EnumUnderlyingType: { } enumUnderlyingType }) - return; - - var stringAction = CodeAction.Create( - title: "Add type parameter to represent as string", - createChangedDocument: cancellationToken => CreateChangedDocumentAsync(context, root, genericSyntaxToReplace, memberTypeSyntax, enumUnderlyingType: null), - equivalenceKey: "AddStringTypeParameter"); - - var numericAction = CodeAction.Create( - title: "Add type parameter to represent numerically", - createChangedDocument: cancellationToken => CreateChangedDocumentAsync(context, root, genericSyntaxToReplace, memberTypeSyntax, enumUnderlyingType), - equivalenceKey: "AddNumericTypeParameter"); - - context.RegisterCodeFix(stringAction, diagnostic); - context.RegisterCodeFix(numericAction, diagnostic); - } - - private static Task CreateChangedDocumentAsync(CodeFixContext context, SyntaxNode root, - GenericNameSyntax genericSyntaxToReplace, TypeSyntax memberTypeSyntax, INamedTypeSymbol? enumUnderlyingType) - { - var underlyingTypeSyntax = enumUnderlyingType is not { SpecialType: { } specialType } - ? SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.StringKeyword)) - : specialType switch - { - SpecialType.System_Byte => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.ByteKeyword)), - SpecialType.System_SByte => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.SByteKeyword)), - SpecialType.System_Int16 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.ShortKeyword)), - SpecialType.System_UInt16 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.UShortKeyword)), - SpecialType.System_Int32 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword)), - SpecialType.System_UInt32 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.UIntKeyword)), - SpecialType.System_Int64 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.LongKeyword)), - SpecialType.System_UInt64 => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.ULongKeyword)), - _ => SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.IntKeyword)) - }; - - var enhancedGenericType = genericSyntaxToReplace.WithTypeArgumentList( - genericSyntaxToReplace.TypeArgumentList.WithArguments( - genericSyntaxToReplace.TypeArgumentList.Arguments.Add(underlyingTypeSyntax))); - var enhancedMemberType = memberTypeSyntax is NullableTypeSyntax - ? (TypeSyntax)SyntaxFactory.NullableType(enhancedGenericType).WithTriviaFrom(memberTypeSyntax) - : enhancedGenericType.WithTriviaFrom(memberTypeSyntax); - var newRoot = root.ReplaceNode(memberTypeSyntax, enhancedMemberType); - return Task.FromResult(context.Document.WithSyntaxRoot(newRoot)); - } -} diff --git a/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs b/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs index d033e40..e4d4020 100644 --- a/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs +++ b/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; using System.Composition; -using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; @@ -40,25 +39,24 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) var ordinalFix = CodeAction.Create( title: "Implement StringComparison { get; } with StringComparison.Ordinal", - createChangedDocument: ct => AddStringComparisonMemberAsync(context.Document, root, tds, stringComparisonExpression: "StringComparison.Ordinal", ct), + createChangedDocument: ct => AddStringComparisonMemberAsync(context.Document, root, tds, stringComparisonExpression: "StringComparison.Ordinal"), equivalenceKey: "ImplementStringComparisonOrdinalGetter"); - context.RegisterCodeFix(ordinalFix, context.Diagnostics.First()); + context.RegisterCodeFix(ordinalFix, diagnostic); var ordinalIgnoreCaseFix = CodeAction.Create( title: "Implement StringComparison { get; } with StringComparison.OrdinalIgnoreCase", - createChangedDocument: ct => AddStringComparisonMemberAsync(context.Document, root, tds, stringComparisonExpression: "StringComparison.OrdinalIgnoreCase", ct), + createChangedDocument: ct => AddStringComparisonMemberAsync(context.Document, root, tds, stringComparisonExpression: "StringComparison.OrdinalIgnoreCase"), equivalenceKey: "ImplementStringComparisonOrdinalIgnoreCaseGetter"); - context.RegisterCodeFix(ordinalIgnoreCaseFix, context.Diagnostics.First()); + context.RegisterCodeFix(ordinalIgnoreCaseFix, diagnostic); } private static Task AddStringComparisonMemberAsync( Document document, SyntaxNode root, TypeDeclarationSyntax tds, - string stringComparisonExpression, - CancellationToken _) + string stringComparisonExpression) { - var newlineTrivia = GetNewlineTrivia(tds); + var newlineTrivia = root.GetNewlineTrivia(); var property = SyntaxFactory.PropertyDeclaration( SyntaxFactory.ParseTypeName("StringComparison"), @@ -76,27 +74,4 @@ private static Task AddStringComparisonMemberAsync( var updatedRoot = root.ReplaceNode(tds, updatedTds); return Task.FromResult(document.WithSyntaxRoot(updatedRoot)); } - - private static SyntaxTrivia GetNewlineTrivia(SyntaxNode node) - { - var allTrivia = node.DescendantTrivia(descendIntoTrivia: true); - - var (nCount, rnCount) = (0, 0); - - foreach (var trivia in allTrivia) - { - if (!trivia.IsKind(SyntaxKind.EndOfLineTrivia)) - continue; - - var length = trivia.Span.Length; - var lengthIsOne = length == 1; - var lengthIsTwo = length == 2; - nCount += Unsafe.As(ref lengthIsOne); - rnCount += Unsafe.As(ref lengthIsTwo); - } - - return rnCount > nCount - ? SyntaxFactory.ElasticCarriageReturnLineFeed - : SyntaxFactory.ElasticLineFeed; - } } diff --git a/DomainModeling.CodeFixProviders/SyntaxNodeExtensions.cs b/DomainModeling.CodeFixProviders/SyntaxNodeExtensions.cs new file mode 100644 index 0000000..b2c4f7c --- /dev/null +++ b/DomainModeling.CodeFixProviders/SyntaxNodeExtensions.cs @@ -0,0 +1,35 @@ +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Architect.DomainModeling.CodeFixProviders; + +internal static class SyntaxNodeExtensions +{ + /// + /// Inspecs the node, such as a root node, for its newline trivia. + /// Returns ElasticCarriageReturnLineFeed if \r\n is the most common, and ElasticLineFeed otherwise. + /// + public static SyntaxTrivia GetNewlineTrivia(this SyntaxNode node) + { + var allTrivia = node.DescendantTrivia(descendIntoTrivia: true); + + var (nCount, rnCount) = (0, 0); + + foreach (var trivia in allTrivia) + { + if (!trivia.IsKind(SyntaxKind.EndOfLineTrivia)) + continue; + + var length = trivia.Span.Length; + var lengthIsOne = length == 1; + var lengthIsTwo = length == 2; + nCount += Unsafe.As(ref lengthIsOne); + rnCount += Unsafe.As(ref lengthIsTwo); + } + + return rnCount > nCount + ? SyntaxFactory.ElasticCarriageReturnLineFeed + : SyntaxFactory.ElasticLineFeed; + } +} diff --git a/DomainModeling.CodeFixProviders/UnvalidatedEnumMemberAssignmentCodeFixer.cs b/DomainModeling.CodeFixProviders/UnvalidatedEnumMemberAssignmentCodeFixer.cs new file mode 100644 index 0000000..04e03bf --- /dev/null +++ b/DomainModeling.CodeFixProviders/UnvalidatedEnumMemberAssignmentCodeFixer.cs @@ -0,0 +1,112 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Simplification; + +namespace Architect.DomainModeling.CodeFixProviders; + +[Shared] +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UnvalidatedEnumMemberAssignmentCodeFixer))] +public sealed class UnvalidatedEnumMemberAssignmentCodeFixer : CodeFixProvider +{ + private static readonly ImmutableArray FixableDiagnosticIdConstant = ["UnvalidatedEnumAssignmentToDomainobject"]; + + public override ImmutableArray FixableDiagnosticIds => FixableDiagnosticIdConstant; + + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var diagnostic = context.Diagnostics.First(diagnostic => diagnostic.Id == FixableDiagnosticIdConstant[0]); + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) return; + + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (node is not ExpressionSyntax unvalidatedValue) + return; + var assignment = node.FirstAncestorOrSelf(); + if (assignment is null) + return; + + var asDefinedAction = CodeAction.Create( + title: "Validate with .AsDefined() extension method", + createChangedDocument: ct => ApplyFixAsync(context.Document, root, assignment, unvalidatedValue, "AsDefined", ct), + equivalenceKey: "ValidateWithAsDefinedExtension"); + var asDefinedFlagsAction = CodeAction.Create( + title: "Validate with .AsDefinedFlags() extension method", + createChangedDocument: ct => ApplyFixAsync(context.Document, root, assignment, unvalidatedValue, "AsDefinedFlags", ct), + equivalenceKey: "ValidateWithAsDefinedFlagsExtension"); + var asUnvalidatedAction = CodeAction.Create( + title: "Condone with .AsUnvalidated() extension method", + createChangedDocument: ct => ApplyFixAsync(context.Document, root, assignment, unvalidatedValue, "AsUnvalidated", ct), + equivalenceKey: "CondoneWithAsUnvalidatedExtension"); + + context.RegisterCodeFix(asDefinedAction, diagnostic); + context.RegisterCodeFix(asDefinedFlagsAction, diagnostic); + context.RegisterCodeFix(asUnvalidatedAction, diagnostic); + } + + private static async Task ApplyFixAsync( + Document document, SyntaxNode root, AssignmentExpressionSyntax assignment, ExpressionSyntax unvalidatedValue, + string methodName, CancellationToken cancellationToken) + { + var chainableValue = unvalidatedValue.WithoutTrivia(); + + // We need parentheses around certain expressions + if (chainableValue is CastExpressionSyntax or BinaryExpressionSyntax or ConditionalExpressionSyntax or SwitchExpressionSyntax or AssignmentExpressionSyntax) + chainableValue = SyntaxFactory.ParenthesizedExpression(chainableValue); + + // For the "default" literal, we need to change it to the default expression "default(T)" + if (chainableValue is LiteralExpressionSyntax { RawKind: (int)SyntaxKind.DefaultLiteralExpression } literal) + chainableValue = await GetDefaultExpressionForDefaultLiteral(literal, assignment, document, cancellationToken).ConfigureAwait(false); + + var wrappedExpression = + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + kind: SyntaxKind.SimpleMemberAccessExpression, + expression: chainableValue, + name: SyntaxFactory.IdentifierName(methodName))); + + // Preserve trivia + wrappedExpression = wrappedExpression.WithTriviaFrom(unvalidatedValue); + + // Ensure presence of the required using declaration + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel is not null && semanticModel.Compilation.GetTypeByMetadataName("Architect.DomainModeling.EnumExtensions") is { } enumExtensionsType) + { + var referenceId = DocumentationCommentId.CreateReferenceId(enumExtensionsType); + var symbolIdAnnotation = new SyntaxAnnotation("SymbolId", referenceId); + + wrappedExpression = wrappedExpression.WithAdditionalAnnotations( + Simplifier.AddImportsAnnotation, symbolIdAnnotation); + } + + var newRoot = root.ReplaceNode(unvalidatedValue, wrappedExpression); + return document.WithSyntaxRoot(newRoot); + } + + private static async Task GetDefaultExpressionForDefaultLiteral(LiteralExpressionSyntax literal, AssignmentExpressionSyntax assignment, Document document, CancellationToken cancellationToken) + { + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel is null) + return literal; + + var targetType = semanticModel.GetTypeInfo(assignment.Left, cancellationToken).Type; + if (targetType is null) + return literal; + + // Type is non-null by definition, or else we would not have gotten the warning + var typeSyntax = SyntaxFactory.ParseTypeName(targetType.ToDisplayString()); + if (typeSyntax is null) + return literal; + + return SyntaxFactory.DefaultExpression(typeSyntax).WithTriviaFrom(literal); + } +} diff --git a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs index b119c08..33d411b 100644 --- a/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs +++ b/DomainModeling.Generator/Configurators/EntityFrameworkConfigurationGenerator.cs @@ -143,7 +143,6 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using Architect.DomainModeling; using Architect.DomainModeling.Configuration; using Architect.DomainModeling.Conversions; -using Architect.DomainModeling.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -361,7 +360,7 @@ file sealed record class ValueWrapperConfigurator( IDatabaseProvider? DatabaseProvider, Action InvokeConfigurationCallbacks, ValueWrapperConfigurationOptions? Options) - : IIdentityConfigurator, IWrapperValueObjectConfigurator, IModelInitializedConvention, IPropertyAddedConvention, IModelFinalizingConvention + : IIdentityConfigurator, IWrapperValueObjectConfigurator, IModelInitializedConvention, IModelFinalizingConvention {{ private Dictionary DesiredCaseSensitivityPerType {{ get; }} = []; @@ -391,23 +390,6 @@ public void ProcessModelInitialized(IConventionModelBuilder modelBuilder, IConve this.InvokeConfigurationCallbacks(); }} - public void ProcessPropertyAdded(IConventionPropertyBuilder propertyBuilder, IConventionContext context) - {{ - // Map DefinedEnum properties - var clrType = propertyBuilder.Metadata.ClrType; - if (clrType.IsConstructedGenericType && clrType.GetGenericTypeDefinition() == typeof(Nullable<>)) - clrType = clrType.GenericTypeArguments[0]; // Dig through nullable - if (clrType.IsConstructedGenericType && clrType.GetGenericTypeDefinition() == typeof(DefinedEnum<,>)) - {{ - var primitiveType = clrType.GenericTypeArguments[1]; - propertyBuilder.HasConverter(typeof(WrapperValueObjectConverter<,>).MakeGenericType(clrType, primitiveType), fromDataAnnotation: true); - if (primitiveType == typeof(string) && propertyBuilder.CanSetMaxLength(64)) - propertyBuilder.HasMaxLength(64); // A reasonable maximum, which the developer can choose to override per property - if (primitiveType == typeof(string) && ValueWrapperConfigurator.GetApplicableCollationFromOptions(StringComparison.OrdinalIgnoreCase, this.Options) is string targetCollation) - propertyBuilder.UseCollation(targetCollation, fromDataAnnotation: true); // Ignore-case for convenience - }} - }} - public void ConfigureIdentity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, TUnderlying, TCore>( in IIdentityConfigurator.Args args) where TIdentity : IIdentity, IDirectValueWrapper, ICoreValueWrapper @@ -649,6 +631,20 @@ file sealed record class EntityFrameworkWrapperValueObjectConfigurator( .UseCollation(targetCollation); }} }} + + [CompilerGenerated] + private sealed class WrapperValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> + : ValueConverter + where TModel : IValueWrapper + {{ + public WrapperValueObjectConverter() + : base( + model => DomainObjectSerializer.Serialize(model)!, + provider => DomainObjectSerializer.Deserialize(provider)!, + mappingHints: null) + {{ + }} + }} }} [CompilerGenerated] @@ -739,20 +735,6 @@ public override InstantiationBinding With(IReadOnlyList parame }} }} - [CompilerGenerated] - file sealed class WrapperValueObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> - : ValueConverter - where TModel : IValueWrapper - {{ - public WrapperValueObjectConverter() - : base( - model => DomainObjectSerializer.Serialize(model)!, - provider => DomainObjectSerializer.Deserialize(provider)!, - mappingHints: null) - {{ - }} - }} - [CompilerGenerated] file sealed class OrdinalStringComparer : ValueComparer {{ diff --git a/DomainModeling.Tests/Analyzers/UnvalidatedEnumMemberAssignmentAnalyzerTests.cs b/DomainModeling.Tests/Analyzers/UnvalidatedEnumMemberAssignmentAnalyzerTests.cs new file mode 100644 index 0000000..8029c07 --- /dev/null +++ b/DomainModeling.Tests/Analyzers/UnvalidatedEnumMemberAssignmentAnalyzerTests.cs @@ -0,0 +1,107 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; + +namespace Architect.DomainModeling.Tests.Analyzers; + +public class UnvalidatedEnumMemberAssignmentAnalyzerTests +{ + // Unfortunately, we always get "unnecessary suppression" even when the warning is successfully suppressed + // All we can do is manually outcomment the suppression temporarily to check that each statement in this file still warns + + private const HttpStatusCode NonexistentStatus = (HttpStatusCode)1; + private const LazyThreadSafetyMode NonexistentThreadSafetyMode = (LazyThreadSafetyMode)999; + + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [SuppressMessage("Usage", "UnvalidatedEnumAssignmentToDomainobject:Unvalidated enum assignment to domain object member", Justification = "Testing presence of warning.")] + public static void AssignUnvalidatedValueToDomainObjectEnumMember_Always_ShouldWarn( + HttpStatusCode statusCode, LazyThreadSafetyMode lazyThreadSafetyMode, + HttpStatusCode? nullableStatusCode, LazyThreadSafetyMode? nullableLazyThreadSafetyMode) + { +#pragma warning disable IDE0034 // Simplify 'default' expression -- Testing presence of warning with this syntax too + + var entity = new TestEntity(); + var valueObject = new TestValueObject(); + + entity.Status = default; + entity.Status = default(HttpStatusCode); + entity.Status = NonexistentStatus; + entity.Status = (HttpStatusCode)1; + entity.Status = statusCode; + entity.Status = (HttpStatusCode)nullableStatusCode!; + entity.Status = nullableStatusCode!.Value; + + //valueObject.LazyThreadSafetyMode = default; // Matches a defined value + //valueObject.LazyThreadSafetyMode = default(LazyThreadSafetyMode); // Matches a defined value + valueObject.LazyThreadSafetyMode = NonexistentThreadSafetyMode; + valueObject.LazyThreadSafetyMode = (LazyThreadSafetyMode)999; + valueObject.LazyThreadSafetyMode = lazyThreadSafetyMode; + valueObject.LazyThreadSafetyMode = (LazyThreadSafetyMode)nullableLazyThreadSafetyMode!; + valueObject.LazyThreadSafetyMode = nullableLazyThreadSafetyMode!.Value; + + //entity.NullableStatus = default; // Null is permitted + //entity.NullableStatus = default(HttpStatusCode?); // Null is permitted + entity.NullableStatus = default(HttpStatusCode); + entity.NullableStatus = NonexistentStatus; + entity.NullableStatus = (HttpStatusCode?)NonexistentStatus; + entity.NullableStatus = (HttpStatusCode)(HttpStatusCode?)NonexistentStatus; + entity.NullableStatus = (HttpStatusCode)1; + entity.NullableStatus = (HttpStatusCode?)1; + entity.NullableStatus = statusCode; + entity.NullableStatus = (HttpStatusCode?)statusCode; + entity.NullableStatus = (HttpStatusCode)(HttpStatusCode?)statusCode; + entity.NullableStatus = nullableStatusCode; + entity.NullableStatus = (HttpStatusCode)nullableStatusCode!; + entity.NullableStatus = nullableStatusCode!.Value; + + //valueObject.NullableLazyThreadSafetyMode = default; // Null is permitted + //valueObject.NullableLazyThreadSafetyMode = default(LazyThreadSafetyMode?); // Null is permitted + //valueObject.NullableLazyThreadSafetyMode = default(LazyThreadSafetyMode); // Matches a defined value + valueObject.NullableLazyThreadSafetyMode = NonexistentThreadSafetyMode; + valueObject.NullableLazyThreadSafetyMode = (LazyThreadSafetyMode?)NonexistentThreadSafetyMode; + valueObject.NullableLazyThreadSafetyMode = (LazyThreadSafetyMode)(LazyThreadSafetyMode?)NonexistentThreadSafetyMode; + valueObject.NullableLazyThreadSafetyMode = (LazyThreadSafetyMode)999; + valueObject.NullableLazyThreadSafetyMode = (LazyThreadSafetyMode?)999; + valueObject.NullableLazyThreadSafetyMode = lazyThreadSafetyMode; + valueObject.NullableLazyThreadSafetyMode = (LazyThreadSafetyMode?)lazyThreadSafetyMode; + valueObject.NullableLazyThreadSafetyMode = (LazyThreadSafetyMode)(LazyThreadSafetyMode?)lazyThreadSafetyMode; + valueObject.NullableLazyThreadSafetyMode = nullableLazyThreadSafetyMode; + valueObject.NullableLazyThreadSafetyMode = (LazyThreadSafetyMode)nullableLazyThreadSafetyMode!; + valueObject.NullableLazyThreadSafetyMode = nullableLazyThreadSafetyMode!.Value; + + // Problematic arms in (nested) ternaries and switches are detected and fixable + entity.NullableStatus = new Random().NextDouble() switch + { + < 0.1 => HttpStatusCode.OK, + < 0.2 => new Random().NextDouble() < 0.5 + ? statusCode + : default(HttpStatusCode?), + _ => null, + }; + entity.NullableStatus = new Random().NextDouble() < 0.5 + ? HttpStatusCode.OK + : new Random().NextDouble() < 0.5 + ? (new Random().NextDouble() < 0.5 + ? new Random().NextDouble() switch + { + < 0.1 => HttpStatusCode.OK, + < 0.2 => statusCode, + _ => null, + } + : throw new NotSupportedException("Example exception")) + : statusCode; + +#pragma warning restore IDE0034 // Simplify 'default' expression + } + + private sealed class TestEntity : IDomainObject + { + public HttpStatusCode Status { get; set; } + public HttpStatusCode? NullableStatus { get; set; } + } + + private sealed class TestValueObject : IValueObject + { + public LazyThreadSafetyMode LazyThreadSafetyMode { get; set; } + public LazyThreadSafetyMode? NullableLazyThreadSafetyMode { get; set; } + } +} diff --git a/DomainModeling.Tests/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs b/DomainModeling.Tests/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs index ba47675..a753ca0 100644 --- a/DomainModeling.Tests/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs +++ b/DomainModeling.Tests/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Net; using Architect.DomainModeling.Tests.IdentityTestTypes; using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; @@ -16,8 +15,6 @@ public static void CompareUnrelatedValueObjects_WithLifting_ShouldWarn() { #pragma warning disable CS0464 // Comparing with null of struct type always produces 'false' -- We still want to test our analyzer on this syntax - _ = new DefinedEnum(HttpStatusCode.OK) > (HttpStatusCode?)null; - _ = (HttpStatusCode?)null > new DefinedEnum(HttpStatusCode.OK); _ = new IntId(1) > null; _ = null > new IntId(1); _ = new DecimalValue(1) > null; diff --git a/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs b/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs index 130bbb4..1b60ea2 100644 --- a/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs +++ b/DomainModeling.Tests/Analyzers/WrapperValueObjectDefaultExpressionAnalyzerTests.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Net; using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes; namespace Architect.DomainModeling.Tests.Analyzers; @@ -14,15 +13,11 @@ public class WrapperValueObjectDefaultExpressionAnalyzerTests public static void UseDefaultExpressionOnWrapperValueObjectStruct_Always_ShouldWarn() { _ = default(DecimalValue); - _ = default(DefinedEnum); } public static void UseDefaultLiteralOnWrapperValueObjectStruct_Always_ShouldWarn() { DecimalValue value = default; _ = value; - - DefinedEnum definedEnum = default; - _ = definedEnum; } } diff --git a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs index 458f421..9504d48 100644 --- a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs @@ -412,7 +412,7 @@ public sealed partial class StringWrapperValueObject : WrapperValueObject("4")), new LazyIntWrapper(new Lazy(5)), - new NumericStringId("6"), - DefinedEnum.Create(HttpStatusCode.OK), - DefinedEnum.Create(HttpStatusCode.Accepted), - DefinedEnum.Create(HttpStatusCode.Created), - null); + new NumericStringId("6")); var entity = new EntityForEF(values); var domainEvent = new DomainEventForEF(id: 2, ignored: null!); @@ -85,10 +80,6 @@ public void ConfigureConventions_WithAllExtensionsCalled_ShouldBeAbleToWorkWithA Assert.Equal("4", reloadedEntity.Values.Four.Value.Value); Assert.Equal(5, reloadedEntity.Values.Five.Value.Value); Assert.Equal("6", reloadedEntity.Values.Six?.Value); - Assert.Equal(HttpStatusCode.OK, reloadedEntity.Values.Seven.Value); - Assert.Equal(HttpStatusCode.Accepted, reloadedEntity.Values.Eight.Value); - Assert.Equal(HttpStatusCode.Created, reloadedEntity.Values.Nine?.Value); - Assert.Null(reloadedEntity.Values.Ten); // This property should be mapped to int via ICoreValueWrapper var mappingForStringWithCustomIntCore = this.DbContext.Model.FindEntityType(typeof(EntityForEF))?.FindNavigation(nameof(EntityForEF.Values))?.TargetEntityType @@ -177,10 +168,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) values.Property(x => x.Four); values.Property(x => x.Five); values.Property(x => x.Six); - values.Property(x => x.Seven); - values.Property(x => x.Eight); - values.Property(x => x.Nine); - values.Property(x => x.Ten); }); builder.HasKey(x => x.Id); @@ -328,10 +315,6 @@ internal sealed partial class ValueObjectForEF public LazyStringWrapper Four { get; private init; } public LazyIntWrapper Five { get; private init; } public NumericStringId? Six { get; private init; } - public DefinedEnum Seven { get; private init; } - public DefinedEnum Eight { get; private init; } - public DefinedEnum? Nine { get; private init; } - public DefinedEnum? Ten { get; private init; } public ValueObjectForEF( Wrapper1ForEF one, @@ -339,11 +322,7 @@ public ValueObjectForEF( FormatAndParseTestingIntId three, LazyStringWrapper four, LazyIntWrapper five, - NumericStringId? six, - DefinedEnum seven, - HttpStatusCode eight, - DefinedEnum? nine, - HttpStatusCode? ten) + NumericStringId? six) { if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors) throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors."); @@ -354,9 +333,5 @@ public ValueObjectForEF( this.Four = four; this.Five = five; this.Six = six; - this.Seven = seven; - this.Eight = DefinedEnum.Create(eight); - this.Nine = nine; - this.Ten = (DefinedEnum?)ten; } } diff --git a/DomainModeling.Tests/Enums/DefinedEnumTests.cs b/DomainModeling.Tests/Enums/DefinedEnumTests.cs index e47a91f..9cd0c74 100644 --- a/DomainModeling.Tests/Enums/DefinedEnumTests.cs +++ b/DomainModeling.Tests/Enums/DefinedEnumTests.cs @@ -1,11 +1,5 @@ using System.Diagnostics.CodeAnalysis; -using Architect.DomainModeling.Conversions; using Xunit; -using Enum = Architect.DomainModeling.DefinedEnum; -using IntEnum = Architect.DomainModeling.DefinedEnum; -using IntWrapper = Architect.DomainModeling.IValueWrapper, int>; -using StringEnum = Architect.DomainModeling.DefinedEnum; -using StringWrapper = Architect.DomainModeling.IValueWrapper, string>; #pragma warning disable IDE0130 // Namespace does not match folder structure namespace Architect.DomainModeling.Tests; @@ -73,779 +67,29 @@ private enum ExampleFlags : uint static DefinedEnumTests() { - DefinedEnum.ExceptionFactoryForNullInput = type => new InvalidOperationException($"{type.Name} expects a non-null value."); - DefinedEnum.ExceptionFactoryForUndefinedInput = (type, numericValue) => new InvalidOperationException($"{type.Name} expects a defined value."); - } - - private static Enum ToEnum(int value) - { - return new Enum((LazyThreadSafetyMode)value); - } - - private static IntEnum ToIntEnum(int value) - { - return new IntEnum((LazyThreadSafetyMode)value); - } - - private static StringEnum ToStringEnum(int value) - { - return new StringEnum((LazyThreadSafetyMode)value); - } - - private static Enum? ToEnum(int? value) - { - return value is null ? null : new Enum((LazyThreadSafetyMode)value); - } - - private static IntEnum? ToIntEnum(int? value) - { - return value is null ? null : new IntEnum((LazyThreadSafetyMode)value); - } - - private static StringEnum? ToStringEnum(int? value) - { - return value is null ? null : new StringEnum((LazyThreadSafetyMode)value); + DefinedEnum.ExceptionFactoryForUndefinedInput = (type, numericValue, errorState) => new InvalidOperationException($"{type.Name} expects a defined value."); } [Fact] public void UndefinedValue_Regularly_ShouldReturnExpectedResult() { - Assert.Equal(~(LazyThreadSafetyMode)(1 | 2), Enum.UndefinedValue); - Assert.Equal((SimpleSByte)(-65), DefinedEnum.UndefinedValue); - Assert.Equal((AdvancedSByte)3, DefinedEnum.UndefinedValue); - Assert.Equal((SimpleShort)(-16397), DefinedEnum.UndefinedValue); - Assert.Equal((AdvancedShort)3, DefinedEnum.UndefinedValue); - Assert.Equal(~(ExampleFlags)(1 | 2 | 4 | 16), DefinedEnum.UndefinedValue); - } - - [Fact] - public void ThrowUndefinedInput_Regularly_ShouldThrow() - { - Assert.Throws(() => Enum.ThrowUndefinedInput()); - } - - [Fact] - public void Construct_WithNull_ShouldThrowConfiguredException() - { - Assert.Throws(() => new Enum(null!)); - Assert.Throws(() => new IntEnum(null!)); - Assert.Throws(() => new StringEnum(null!)); - } - - [Fact] - public void Construct_WithUndefinedValue_ShouldThrowConfiguredException() - { - Assert.Throws(() => new Enum((LazyThreadSafetyMode)(-1))); - Assert.Throws(() => new IntEnum((LazyThreadSafetyMode)(-1))); - Assert.Throws(() => new StringEnum((LazyThreadSafetyMode)(-1))); + Assert.Equal(~(LazyThreadSafetyMode)(1 | 2), DefinedEnum.UndefinedValues.UndefinedValue); + Assert.Equal((SimpleSByte)(-65), DefinedEnum.UndefinedValues.UndefinedValue); + Assert.Equal((AdvancedSByte)3, DefinedEnum.UndefinedValues.UndefinedValue); + Assert.Equal((SimpleShort)(-16397), DefinedEnum.UndefinedValues.UndefinedValue); + Assert.Equal((AdvancedShort)3, DefinedEnum.UndefinedValues.UndefinedValue); + Assert.Equal(~(ExampleFlags)(1 | 2 | 4 | 16), DefinedEnum.UndefinedValues.UndefinedValue); } [Fact] - public void Construct_WithUndefinedBitForFlags_ShouldThrowConfiguredException() + public void ThrowUndefinedInput_WithNongenericOverload_ShouldThrowConfiguredException() { - Assert.Throws(() => new DefinedEnum((ExampleFlags)8)); - Assert.Throws(() => new DefinedEnum((ExampleFlags)8)); - Assert.Throws(() => new DefinedEnum((ExampleFlags)8)); + Assert.Throws(() => DefinedEnum.ThrowUndefinedInput(typeof(LazyThreadSafetyMode), numericValue: 999, errorState: null)); } [Fact] - public void Construct_Regularly_ShouldHaveExpectedValue() - { - Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new Enum(LazyThreadSafetyMode.PublicationOnly).Value); - Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new IntEnum(LazyThreadSafetyMode.PublicationOnly).Value); - Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new StringEnum(LazyThreadSafetyMode.PublicationOnly).Value); - } - - [Fact] - public void Construct_FromNullableValue_ShouldHaveExpectedValue() - { - Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new Enum((LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly).Value); - Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new IntEnum((LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly).Value); - Assert.Equal(LazyThreadSafetyMode.PublicationOnly, new StringEnum((LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly).Value); - } - - [Fact] - public void Construct_WithValidFlags_ShouldHaveExpectedValue() - { - Assert.Equal(ExampleFlags.One | ExampleFlags.Sixteen, new DefinedEnum(ExampleFlags.Zero | ExampleFlags.One | ExampleFlags.Sixteen).Value); - Assert.Equal(ExampleFlags.One | ExampleFlags.Sixteen, new DefinedEnum(ExampleFlags.Zero | ExampleFlags.One | ExampleFlags.Sixteen).Value); - Assert.Equal(ExampleFlags.One | ExampleFlags.Sixteen, new DefinedEnum(ExampleFlags.Zero | ExampleFlags.One | ExampleFlags.Sixteen).Value); - } - - [Fact] - public void ToString_Regularly_ShouldReturnExpectedResult() - { - Assert.Equal("PublicationOnly", ToEnum(1).ToString()); - Assert.Equal("PublicationOnly", ToIntEnum(1).ToString()); - Assert.Equal("PublicationOnly", ToStringEnum(1).ToString()); - } - - [Fact] - public void GetHashCode_Regulary_ShouldReturnExpectedResult() - { - Assert.Equal(1.GetHashCode(), ToEnum(1).GetHashCode()); - Assert.Equal(1.GetHashCode(), ToIntEnum(1).GetHashCode()); - Assert.Equal(1.GetHashCode(), ToStringEnum(1).GetHashCode()); - } - - [Fact] - [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] - [SuppressMessage("Design", "WrapperValueObjectDefaultExpression:Default expression instantiating unvalidated value object", Justification = "Needs to work, despite being bad practice.")] - public void GetHashCode_WithDefaultInstance_ShouldReturnExpectedResult() - { - var instance = default(Enum); - Assert.Equal(0, instance.GetHashCode()); - var intInstance = default(IntEnum); - Assert.Equal(0, intInstance.GetHashCode()); - var stringInstance = default(StringEnum); - Assert.Equal(0, stringInstance.GetHashCode()); - } - - [Theory] - [InlineData(0, 0, true)] - [InlineData(0, 1, false)] - [InlineData(1, 2, false)] - public void Equals_Regularly_ShouldReturnExpectedResult(int one, int two, bool expectedResult) - { - Assert.Equal(expectedResult, ToEnum(one).Equals(ToEnum(two))); - Assert.Equal(expectedResult, ToIntEnum(one).Equals(ToIntEnum(two))); - Assert.Equal(expectedResult, ToStringEnum(one).Equals(ToStringEnum(two))); - - Assert.Equal(expectedResult, ToEnum(one).Equals(ToIntEnum(two))); - Assert.Equal(expectedResult, ToEnum(one).Equals(ToStringEnum(two))); - Assert.Equal(expectedResult, ToIntEnum(one).Equals(ToEnum(two))); - Assert.Equal(expectedResult, ToIntEnum(one).Equals(ToStringEnum(two))); - Assert.Equal(expectedResult, ToStringEnum(one).Equals(ToEnum(two))); - Assert.Equal(expectedResult, ToStringEnum(one).Equals(ToIntEnum(two))); - } - - [Fact] - [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] - [SuppressMessage("Design", "WrapperValueObjectDefaultExpression:Default expression instantiating unvalidated value object", Justification = "Needs to work, despite being bad practice.")] - public void Equals_WithDefaultInstance_ShouldReturnExpectedResult() + public void ThrowUndefinedInput_WithGenericOverload_ShouldThrowConfiguredException() { - Assert.NotEqual(default, ToEnum(1)); - Assert.NotEqual(ToEnum(1), default); - - Assert.NotEqual(default, ToIntEnum(1)); - Assert.NotEqual(ToIntEnum(1), default); - - Assert.NotEqual(default, ToStringEnum(1)); - Assert.NotEqual(ToStringEnum(1), default); - } - - [Theory] - [InlineData(0, 0)] - [InlineData(0, 1)] - [InlineData(1, 2)] - public void EqualityOperator_Regularly_ShouldMatchEquals(int one, int two) - { - Assert.Equal(ToEnum(one).Equals(ToEnum(two)), ToEnum(one) == ToEnum(two)); - Assert.Equal(ToEnum(one).Equals(ToEnum(two)), ToEnum(one) == ToEnum(two).Value); - Assert.Equal(ToEnum(one).Equals(ToEnum(two)), ToEnum(one).Value == ToEnum(two)); - - Assert.Equal(ToIntEnum(one).Equals(ToIntEnum(two)), ToIntEnum(one) == ToIntEnum(two)); - Assert.Equal(ToIntEnum(one).Equals(ToIntEnum(two)), ToIntEnum(one) == ToIntEnum(two).Value); - Assert.Equal(ToIntEnum(one).Equals(ToIntEnum(two)), ToIntEnum(one).Value == ToIntEnum(two)); - - Assert.Equal(ToStringEnum(one).Equals(ToStringEnum(two)), ToStringEnum(one) == ToStringEnum(two)); - Assert.Equal(ToStringEnum(one).Equals(ToStringEnum(two)), ToStringEnum(one) == ToStringEnum(two).Value); - Assert.Equal(ToStringEnum(one).Equals(ToStringEnum(two)), ToStringEnum(one).Value == ToStringEnum(two)); - - Assert.Equal(ToEnum(one).Equals(ToIntEnum(two)), ToEnum(one) == ToIntEnum(two)); - Assert.Equal(ToEnum(one).Equals(ToStringEnum(two)), ToEnum(one) == ToStringEnum(two)); - Assert.Equal(ToIntEnum(one).Equals(ToEnum(two)), ToIntEnum(one) == ToEnum(two)); - Assert.Equal(ToStringEnum(one).Equals(ToEnum(two)), ToStringEnum(one) == ToEnum(two)); - - // Cannot compare directly between different primitive representations - can simply take their values for such comparisons - //Assert.Equal(ToIntEnum(one).Equals(ToStringEnum(two)), ToIntEnum(one) == ToStringEnum(two)); - //Assert.Equal(ToStringEnum(one).Equals(ToIntEnum(two)), ToStringEnum(one) == ToIntEnum(two)); - } - - [Fact] - public void EqualityOperator_WithNullables_ShouldReturnExpectedResult() - { -#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary -#pragma warning disable IDE0004 // Remove unnecessary cast -- Deliberate casts to test specific operators -#pragma warning disable CS8073 // The result of the expression is always 'false' (or 'true') -- Deliberate casts to test specific operators -#pragma warning disable xUnit2024 // Do not use boolean asserts for simple equality tests -- Deliberate test of specific operators - Assert.True((Enum?)null == (Enum?)null); - Assert.True((IntEnum?)null == (IntEnum?)null); - Assert.True((StringEnum?)null == (StringEnum?)null); - - Assert.False((Enum?)null == LazyThreadSafetyMode.PublicationOnly); - Assert.False((IntEnum?)null == LazyThreadSafetyMode.PublicationOnly); - Assert.False((StringEnum?)null == LazyThreadSafetyMode.PublicationOnly); - Assert.True((Enum?)ToEnum(1) == LazyThreadSafetyMode.PublicationOnly); - Assert.True((IntEnum?)ToIntEnum(1) == LazyThreadSafetyMode.PublicationOnly); - Assert.True((StringEnum?)ToStringEnum(1) == LazyThreadSafetyMode.PublicationOnly); - - // Analyzers disallows implicit conversion to DefinedEnum from non-constant - //Assert.False((Enum?)null == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - //Assert.False((IntEnum?)null == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - //Assert.False((StringEnum?)null == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - //Assert.True((Enum?)ToEnum(1) == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - //Assert.True((IntEnum?)ToIntEnum(1) == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - //Assert.True((StringEnum?)ToStringEnum(1) == (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - - Assert.False((Enum?)null == (Enum?)ToEnum(0)); - Assert.False((IntEnum?)null == (IntEnum?)ToIntEnum(0)); - Assert.False((StringEnum?)null == (StringEnum?)ToStringEnum(0)); - Assert.False((Enum?)ToEnum(0) == (Enum?)null); - Assert.False((IntEnum?)ToIntEnum(0) == (IntEnum?)null); - Assert.False((StringEnum?)ToStringEnum(0) == (StringEnum?)null); - - Assert.True((Enum?)ToEnum(0) == (Enum?)ToEnum(0)); - Assert.True((IntEnum?)ToIntEnum(0) == (IntEnum?)ToIntEnum(0)); - Assert.True((StringEnum?)ToStringEnum(0) == (StringEnum?)ToStringEnum(0)); - - // Comparisons between nullables of different enum-related types are ambiguous, which is easy enough to work around by using their values - //Assert.True((Enum?)ToEnum(0) == (IntEnum?)ToIntEnum(0)); - //Assert.True((Enum?)ToEnum(0) == (StringEnum?)ToStringEnum(0)); - //Assert.True((IntEnum?)ToIntEnum(0) == (Enum?)ToEnum(0)); - //Assert.True((StringEnum?)ToStringEnum(0) == (Enum?)ToEnum(0)); - //Assert.True((IntEnum?)ToIntEnum(0) == (StringEnum?)ToStringEnum(0)); - //Assert.True((StringEnum?)ToStringEnum(0) == (IntEnum?)ToIntEnum(0)); -#pragma warning restore xUnit2024 -#pragma warning restore CS8073 -#pragma warning restore IDE0004 -#pragma warning restore IDE0079 - } - - [Fact] - public void InequalityOperator_WithNullables_ShouldReturnExpectedResult() - { -#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary -#pragma warning disable IDE0004 // Remove unnecessary cast -- Deliberate casts to test specific operators -#pragma warning disable CS8073 // The result of the expression is always 'false' (or 'true') -- Deliberate casts to test specific operators -#pragma warning disable xUnit2024 // Do not use boolean asserts for simple equality tests -- Deliberate test of specific operators - Assert.False((Enum?)null != (Enum?)null); - Assert.False((IntEnum?)null != (IntEnum?)null); - Assert.False((StringEnum?)null != (StringEnum?)null); - - Assert.True((Enum?)null != LazyThreadSafetyMode.PublicationOnly); - Assert.True((IntEnum?)null != LazyThreadSafetyMode.PublicationOnly); - Assert.True((StringEnum?)null != LazyThreadSafetyMode.PublicationOnly); - Assert.False((Enum?)ToEnum(1) != LazyThreadSafetyMode.PublicationOnly); - Assert.False((IntEnum?)ToIntEnum(1) != LazyThreadSafetyMode.PublicationOnly); - Assert.False((StringEnum?)ToStringEnum(1) != LazyThreadSafetyMode.PublicationOnly); - - // Analyzers disallows implicit conversion to DefinedEnum from non-constant - //Assert.True((Enum?)null != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - //Assert.True((IntEnum?)null != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - //Assert.True((StringEnum?)null != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - //Assert.False((Enum?)ToEnum(1) != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - //Assert.False((IntEnum?)ToIntEnum(1) != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - //Assert.False((StringEnum?)ToStringEnum(1) != (LazyThreadSafetyMode?)LazyThreadSafetyMode.PublicationOnly); - - Assert.True((Enum?)null != (Enum?)ToEnum(0)); - Assert.True((IntEnum?)null != (IntEnum?)ToIntEnum(0)); - Assert.True((StringEnum?)null != (StringEnum?)ToStringEnum(0)); - Assert.True((Enum?)ToEnum(0) != (Enum?)null); - Assert.True((IntEnum?)ToIntEnum(0) != (IntEnum?)null); - Assert.True((StringEnum?)ToStringEnum(0) != (StringEnum?)null); - - Assert.False((Enum?)ToEnum(0) != (Enum?)ToEnum(0)); - Assert.False((IntEnum?)ToIntEnum(0) != (IntEnum?)ToIntEnum(0)); - Assert.False((StringEnum?)ToStringEnum(0) != (StringEnum?)ToStringEnum(0)); - - // Comparisons between nullables of different enum-related types are ambiguous, which is easy enough to work around by using their values - //Assert.False((Enum?)ToEnum(0) != (IntEnum?)ToIntEnum(0)); - //Assert.False((Enum?)ToEnum(0) != (StringEnum?)ToStringEnum(0)); - //Assert.False((IntEnum?)ToIntEnum(0) != (Enum?)ToEnum(0)); - //Assert.False((StringEnum?)ToStringEnum(0) != (Enum?)ToEnum(0)); - //Assert.False((IntEnum?)ToIntEnum(0) != (StringEnum?)ToStringEnum(0)); - //Assert.False((StringEnum?)ToStringEnum(0) != (IntEnum?)ToIntEnum(0)); -#pragma warning restore xUnit2024 -#pragma warning restore CS8073 -#pragma warning restore IDE0004 -#pragma warning restore IDE0079 - } - - [Theory] - [InlineData(0, 0)] - [InlineData(0, 1)] - [InlineData(1, 0)] - public void CompareTo_WithEqualValues_ShouldHaveEqualityMatchingEquality(int one, int two) - { - Assert.Equal(one.Equals(two), ToEnum(one).CompareTo(ToEnum(two)) == 0); - Assert.Equal(one.Equals(two), ToIntEnum(one).CompareTo(ToIntEnum(two)) == 0); - Assert.Equal(one.Equals(two), ToStringEnum(one).CompareTo(ToStringEnum(two)) == 0); - - Assert.Equal(one.Equals(two), ToEnum(one).CompareTo(ToIntEnum(two)) == 0); - Assert.Equal(one.Equals(two), ToEnum(one).CompareTo(ToStringEnum(two)) == 0); - Assert.Equal(one.Equals(two), ToIntEnum(one).CompareTo(ToEnum(two)) == 0); - Assert.Equal(one.Equals(two), ToIntEnum(one).CompareTo(ToStringEnum(two)) == 0); - Assert.Equal(one.Equals(two), ToStringEnum(one).CompareTo(ToEnum(two)) == 0); - Assert.Equal(one.Equals(two), ToStringEnum(one).CompareTo(ToIntEnum(two)) == 0); - } - - [Theory] - [InlineData(null, null, 0)] - [InlineData(null, 0, -1)] - [InlineData(0, null, +1)] - [InlineData(0, 0, 0)] - [InlineData(0, 1, -1)] - [InlineData(1, 0, +1)] - public void CompareTo_Regularly_ShouldReturnExpectedResult(int? one, int? two, int expectedResult) - { - Assert.Equal(expectedResult, Comparer.Default.Compare(ToEnum(one), ToEnum(two))); - Assert.Equal(-expectedResult, Comparer.Default.Compare(ToEnum(two), ToEnum(one))); - Assert.Equal(expectedResult, Comparer.Default.Compare(ToIntEnum(one), ToIntEnum(two))); - Assert.Equal(-expectedResult, Comparer.Default.Compare(ToIntEnum(two), ToIntEnum(one))); - Assert.Equal(expectedResult, Comparer.Default.Compare(ToStringEnum(one), ToStringEnum(two))); - Assert.Equal(-expectedResult, Comparer.Default.Compare(ToStringEnum(two), ToStringEnum(one))); - - Assert.Equal(expectedResult, Comparer.Default.Compare(ToEnum(one), ToIntEnum(two))); - Assert.Equal(expectedResult, Comparer.Default.Compare(ToEnum(one), ToStringEnum(two))); - - Assert.Equal(expectedResult, Comparer.Default.Compare(ToIntEnum(one), ToEnum(two))); - Assert.Equal(expectedResult, Comparer.Default.Compare(ToStringEnum(one), ToEnum(two))); - } - - [Theory] - [InlineData(0, 0, 0)] - [InlineData(0, 1, -1)] - [InlineData(1, 0, +1)] - public void GreaterThan_Regularly_ShouldReturnExpectedResult(int one, int two, int expectedResult) - { - Assert.Equal(expectedResult > 0, ToEnum(one) > ToEnum(two)); - Assert.Equal(expectedResult > 0, ToEnum(one) > ToEnum(two).Value); - Assert.Equal(expectedResult > 0, ToEnum(one).Value > ToEnum(two)); - Assert.Equal(expectedResult <= 0, ToEnum(one) <= ToEnum(two)); - Assert.Equal(expectedResult <= 0, ToEnum(one) <= ToEnum(two).Value); - Assert.Equal(expectedResult <= 0, ToEnum(one).Value <= ToEnum(two)); - - Assert.Equal(expectedResult > 0, ToIntEnum(one) > ToIntEnum(two)); - Assert.Equal(expectedResult > 0, ToIntEnum(one) > ToIntEnum(two).Value); - Assert.Equal(expectedResult > 0, ToIntEnum(one).Value > ToIntEnum(two)); - Assert.Equal(expectedResult <= 0, ToIntEnum(one) <= ToIntEnum(two)); - Assert.Equal(expectedResult <= 0, ToIntEnum(one) <= ToIntEnum(two).Value); - Assert.Equal(expectedResult <= 0, ToIntEnum(one).Value <= ToIntEnum(two)); - - Assert.Equal(expectedResult > 0, ToStringEnum(one) > ToStringEnum(two)); - Assert.Equal(expectedResult > 0, ToStringEnum(one) > ToStringEnum(two).Value); - Assert.Equal(expectedResult > 0, ToStringEnum(one).Value > ToStringEnum(two)); - Assert.Equal(expectedResult <= 0, ToStringEnum(one) <= ToStringEnum(two)); - Assert.Equal(expectedResult <= 0, ToStringEnum(one) <= ToStringEnum(two).Value); - Assert.Equal(expectedResult <= 0, ToStringEnum(one).Value <= ToStringEnum(two)); - - Assert.Equal(expectedResult > 0, ToEnum(one) > ToIntEnum(two)); - Assert.Equal(expectedResult > 0, ToEnum(one) > ToStringEnum(two)); - Assert.Equal(expectedResult > 0, ToIntEnum(one) > ToEnum(two)); - Assert.Equal(expectedResult > 0, ToStringEnum(one) > ToEnum(two)); - - // Cannot compare directly between different primitive representations - can simply take their values for such comparisons - //Assert.Equal(expectedResult > 0, ToIntEnum(one) > ToStringEnum(two)); - //Assert.Equal(expectedResult > 0, ToStringEnum(one) > ToIntEnum(two)); - } - - [Theory] - [InlineData(0, 0, 0)] - [InlineData(0, 1, -1)] - [InlineData(1, 0, +1)] - public void LessThan_Regularly_ShouldReturnExpectedResult(int one, int two, int expectedResult) - { - Assert.Equal(expectedResult < 0, ToEnum(one) < ToEnum(two)); - Assert.Equal(expectedResult < 0, ToEnum(one) < ToEnum(two).Value); - Assert.Equal(expectedResult < 0, ToEnum(one).Value < ToEnum(two)); - Assert.Equal(expectedResult >= 0, ToEnum(one) >= ToEnum(two)); - Assert.Equal(expectedResult >= 0, ToEnum(one) >= ToEnum(two).Value); - Assert.Equal(expectedResult >= 0, ToEnum(one).Value >= ToEnum(two)); - - Assert.Equal(expectedResult < 0, ToIntEnum(one) < ToIntEnum(two)); - Assert.Equal(expectedResult < 0, ToIntEnum(one) < ToIntEnum(two).Value); - Assert.Equal(expectedResult < 0, ToIntEnum(one).Value < ToIntEnum(two)); - Assert.Equal(expectedResult >= 0, ToIntEnum(one) >= ToIntEnum(two)); - Assert.Equal(expectedResult >= 0, ToIntEnum(one) >= ToIntEnum(two).Value); - Assert.Equal(expectedResult >= 0, ToIntEnum(one).Value >= ToIntEnum(two)); - - Assert.Equal(expectedResult < 0, ToStringEnum(one) < ToStringEnum(two)); - Assert.Equal(expectedResult < 0, ToStringEnum(one) < ToStringEnum(two).Value); - Assert.Equal(expectedResult < 0, ToStringEnum(one).Value < ToStringEnum(two)); - Assert.Equal(expectedResult >= 0, ToStringEnum(one) >= ToStringEnum(two)); - Assert.Equal(expectedResult >= 0, ToStringEnum(one) >= ToStringEnum(two).Value); - Assert.Equal(expectedResult >= 0, ToStringEnum(one).Value >= ToStringEnum(two)); - - Assert.Equal(expectedResult < 0, ToEnum(one) < ToIntEnum(two)); - Assert.Equal(expectedResult < 0, ToEnum(one) < ToStringEnum(two)); - Assert.Equal(expectedResult < 0, ToIntEnum(one) < ToEnum(two)); - Assert.Equal(expectedResult < 0, ToStringEnum(one) < ToEnum(two)); - - // Cannot compare directly between different primitive representations - can simply take their values for such comparisons - //Assert.Equal(expectedResult < 0, ToIntEnum(one) < ToStringEnum(two)); - //Assert.Equal(expectedResult < 0, ToStringEnum(one) < ToIntEnum(two)); - } - - [Theory] - [InlineData(null, null)] - [InlineData(0, 0)] - [InlineData(1, 1)] - public void CastToUnderlyingType_Regularly_ShouldReturnExpectedResultOrThrowConfiguredException(int? value, int? expectedResult) - { - var intInstance = ToIntEnum(value); - var stringInstance = ToStringEnum(value); - - if (expectedResult is null) - { - // Configured exception - Assert.Throws(() => (int)(LazyThreadSafetyMode)intInstance!); - Assert.Throws(() => (int)(LazyThreadSafetyMode)stringInstance!); - } - else - { - Assert.Equal(expectedResult, (int)(LazyThreadSafetyMode)intInstance!); - Assert.Equal(expectedResult, (int)(LazyThreadSafetyMode)stringInstance!); - } - } - - [Theory] - [InlineData(null, null)] - [InlineData(0, 0)] - [InlineData(1, 1)] - public void CastToNullableUnderlyingType_Regularly_ShouldReturnExpectedResult(int? value, int? expectedResult) - { - var intInstance = ToIntEnum(value); - var stringInstance = ToStringEnum(value); - - Assert.Equal(expectedResult, (int?)(LazyThreadSafetyMode?)intInstance); - Assert.Equal(expectedResult, (int?)(LazyThreadSafetyMode?)stringInstance); - } - - [Theory] - [InlineData(0, 0)] - [InlineData(1, 1)] - public void CastFromUnderlyingType_Regularly_ShouldReturnExpectedResult(int value, int expectedResult) - { - Assert.Equal(ToIntEnum(expectedResult), (IntEnum)(LazyThreadSafetyMode)value); - Assert.Equal(ToStringEnum(expectedResult), (StringEnum)(LazyThreadSafetyMode)value); - } - - [Theory] - [InlineData(null, null)] - [InlineData(0, 0)] - [InlineData(1, 1)] - public void CastFromNullableUnderlyingType_Regularly_ShouldReturnExpectedResult(int? value, int? expectedResult) - { - Assert.Equal((LazyThreadSafetyMode?)expectedResult, ((IntEnum?)(LazyThreadSafetyMode?)value)?.Value); - Assert.Equal((LazyThreadSafetyMode?)expectedResult, ((StringEnum?)(LazyThreadSafetyMode?)value)?.Value); - } - - [Theory] - [InlineData(0, 0)] - [InlineData(1, 1)] - public void CastToNumericType_Regularly_ShouldReturnExpectedResult(int value, int expectedResult) - { - Assert.Equal(expectedResult, (Int128)ToIntEnum(value)); - Assert.Equal(expectedResult, (Int128)ToStringEnum(value)); - } - - [Theory] - [InlineData(null, null)] - [InlineData(0, 0)] - [InlineData(1, 1)] - public void CastToNullableCoreType_Regularly_ShouldReturnExpectedResult(int? value, int? expectedResult) - { - Assert.Equal(expectedResult, (Int128?)ToIntEnum(value)); - Assert.Equal(expectedResult, (Int128?)ToStringEnum(value)); - } - - [Fact] - public void CastBetweenWithAndWithoutPrimitive_Regularly_ShouldReturnExpectedResult() - { - Assert.Equal(ToEnum(1), (Enum)ToIntEnum(1)); - Assert.Equal(ToEnum(1), (Enum?)ToIntEnum(1)); - Assert.Equal(ToEnum(1), (Enum)ToStringEnum(1)); - Assert.Equal(ToEnum(1), (Enum?)ToStringEnum(1)); - - Assert.Equal(ToIntEnum(1), (IntEnum)ToEnum(1)); - Assert.Equal(ToIntEnum(1), (IntEnum?)ToEnum(1)); - Assert.Equal(ToStringEnum(1), (StringEnum)ToEnum(1)); - Assert.Equal(ToStringEnum(1), (StringEnum?)ToEnum(1)); - } - - [Theory] - [InlineData(0, "None")] - [InlineData(1, "PublicationOnly")] - public void Value_ViaCoreValueInterface_ShouldReturnExpectedResult(int value, string stringValue) - { - IntWrapper intInstance = ToIntEnum(value); - Assert.IsType(intInstance.Value); - Assert.Equal(value, intInstance.Value); - - StringWrapper stringInstance = ToStringEnum(value); - Assert.IsType(stringInstance.Value); - Assert.Equal(stringValue, stringInstance.Value); - } - - /// - /// Helper to access abstract statics. - /// - private static TWrapper CreateFromCoreValue(TValue value) - where TWrapper : ICoreValueWrapper - { - return TWrapper.Create(value); - } - - [Theory] - [InlineData(0, "None")] - [InlineData(1, "PublicationOnly")] - public void Create_ViaCoreValueInterface_ShouldReturnExpectedResult(int value, string stringValue) - { - Assert.IsType(CreateFromCoreValue(value)); - Assert.Equal((LazyThreadSafetyMode)value, CreateFromCoreValue(value).Value); - - Assert.IsType(CreateFromCoreValue(stringValue)); - Assert.Equal((LazyThreadSafetyMode)value, CreateFromCoreValue(value.ToString()).Value); - } - - [Theory] - [InlineData(0, "None")] - [InlineData(1, "PublicationOnly")] - public void Serialize_ToCoreType_ShouldReturnExpectedResult(int value, string stringValue) - { - IntWrapper intInstance = ToIntEnum(value); - Assert.IsType(intInstance.Serialize()); - Assert.Equal(value, intInstance.Serialize()); - - StringWrapper stringInstance = ToStringEnum(value); - Assert.IsType(stringInstance.Serialize()); - Assert.Equal(stringValue, stringInstance.Serialize()); - } - - [Theory] - [InlineData(null, null)] - [InlineData(0, "None")] - [InlineData(1, "PublicationOnly")] - public void SerializeWithSystemTextJson_Regularly_ShouldReturnExpectedResult(int? value, string? stringValue) - { - var intInstance = ToIntEnum(value); - Assert.Equal(value?.ToString() ?? "null", System.Text.Json.JsonSerializer.Serialize(intInstance)); - - var stringInstance = ToStringEnum(value); - Assert.Equal(value is null ? "null" : $@"""{stringValue}""", System.Text.Json.JsonSerializer.Serialize(stringInstance)); - } - - [Theory] - [InlineData(null, null)] - [InlineData(0, "None")] - [InlineData(1, "PublicationOnly")] - public void SerializeWithNewtonsoftJson_Regularly_ShouldReturnExpectedResult(int? value, string? stringValue) - { - var intInstance = ToIntEnum(value); - Assert.Equal(value?.ToString() ?? "null", Newtonsoft.Json.JsonConvert.SerializeObject(intInstance)); - - var stringInstance = ToStringEnum(value); - Assert.Equal(value is null ? "null" : $@"""{stringValue}""", Newtonsoft.Json.JsonConvert.SerializeObject(stringInstance)); - } - - /// - /// Helper to access abstract statics. - /// - private static TWrapper Deserialize(TValue value) - where TWrapper : IValueWrapper - { - return TWrapper.Deserialize(value); - } - - [Theory] - [InlineData(0, "None")] - [InlineData(1, "PublicationOnly")] - public void Deserialize_FromCoreType_ShouldReturnExpectedResult(int value, string stringValue) - { - Assert.IsType(Deserialize(value)); - Assert.Equal(value, (int)Deserialize(value).Value); - - Assert.IsType(Deserialize(stringValue)); - Assert.Equal(value, (int)Deserialize(stringValue).Value); - } - - /// - /// Deserialization bypasses validation. - /// - [Fact] - public void Deserialize_FromUndefinedCoreValue_ShouldReturnExpectedResult() - { - // From numeric we get that particular value, undefined though it may be - Assert.Equal((LazyThreadSafetyMode)(-1), Deserialize(-1).Value); - - // From string we get our default undefined value - Assert.Equal(~(LazyThreadSafetyMode)(1 | 2), Deserialize("Nonexistent").Value); - Assert.Equal((SimpleSByte)(-65), Deserialize, string>("Nonexistent").Value); // Preferred - Assert.Equal((AdvancedSByte)3, Deserialize, string>("Nonexistent").Value); // Found by scanning - Assert.Equal((SimpleShort)(-16397), Deserialize, string>("Nonexistent").Value); // Fallback when preferred not available - Assert.Equal((AdvancedShort)3, Deserialize, string>("Nonexistent").Value); // Found by scanning - } - - [Theory] - [InlineData("null", null, null)] - [InlineData("0", 0, "None")] - [InlineData("1", 1, "PublicationOnly")] - public void DeserializeWithSystemTextJson_Regularly_ShouldReturnExpectedResult(string json, int? value, string? stringValue) - { - Assert.Equal(value, (int?)System.Text.Json.JsonSerializer.Deserialize(json)?.Value); - - json = json == "null" ? json : $@"""{stringValue}"""; - Assert.Equal(value, (int?)System.Text.Json.JsonSerializer.Deserialize(json)?.Value); - } - - [Theory] - [InlineData("null", null, null)] - [InlineData("0", 0, "None")] - [InlineData("1", 1, "PublicationOnly")] - public void DeserializeWithNewtonsoftJson_Regularly_ShouldReturnExpectedResult(string json, int? value, string? stringValue) - { - if (value is not null) // To also test deserialization NOT wrapped in a nullable - Assert.Equal(value, (int)Newtonsoft.Json.JsonConvert.DeserializeObject(json).Value); - - json = json == "null" ? json : $@"""{stringValue}"""; - Assert.Equal(value, (int?)Newtonsoft.Json.JsonConvert.DeserializeObject(json)?.Value); - } - - [Theory] - [InlineData(0, "None")] - [InlineData(1, "PublicationOnly")] - public void ReadAsPropertyNameWithSystemTextJson_Regularly_ShouldReturnExpectedResult(int value, string stringValue) - { - Assert.Equal(KeyValuePair.Create(ToIntEnum(value), true), System.Text.Json.JsonSerializer.Deserialize>($$"""{ "{{value}}": true }""")?.Single()); - - Assert.Equal(KeyValuePair.Create(ToStringEnum(value), true), System.Text.Json.JsonSerializer.Deserialize>($$"""{ "{{stringValue}}": true }""")?.Single()); - } - - [Theory] - [InlineData(0, "None")] - [InlineData(1, "PublicationOnly")] - public void WriteAsPropertyNameWithSystemTextJson_Regularly_ShouldReturnExpectedResult(int value, string stringValue) - { - Assert.Equal($$"""{"{{value}}":true}""", System.Text.Json.JsonSerializer.Serialize(new Dictionary() { [ToIntEnum(value)] = true })); - - Assert.Equal($$"""{"{{stringValue}}":true}""", System.Text.Json.JsonSerializer.Serialize(new Dictionary() { [ToStringEnum(value)] = true })); - } - - [Fact] - [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] - [SuppressMessage("Design", "WrapperValueObjectDefaultExpression:Default expression instantiating unvalidated value object", Justification = "Needs to work, despite being bad practice.")] - public void FormattableToString_InAllScenarios_ShouldReturnExpectedResult() - { - Assert.Equal("None", default(Enum).ToString(format: null, formatProvider: null)); - Assert.Equal("None", default(IntEnum).ToString(format: null, formatProvider: null)); - Assert.Equal("None", default(StringEnum).ToString(format: null, formatProvider: null)); - Assert.Equal("PublicationOnly", ToEnum(1).ToString(format: null, formatProvider: null)); - Assert.Equal("PublicationOnly", ToIntEnum(1).ToString(format: null, formatProvider: null)); - Assert.Equal("PublicationOnly", ToStringEnum(1).ToString(format: null, formatProvider: null)); - - Assert.Equal("-1", DomainObjectSerializer.Deserialize(-1).ToString(format: null, formatProvider: null)); - } - - [Fact] - public void SpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResult() - { - Span result = stackalloc char[15]; - - Assert.True(ToEnum(1).TryFormat(result, out var charsWritten, format: null, provider: null)); - Assert.Equal(15, charsWritten); - Assert.Equal("PublicationOnly".AsSpan(), result); - - Assert.True(ToIntEnum(1).TryFormat(result, out charsWritten, format: null, provider: null)); - Assert.Equal(15, charsWritten); - Assert.Equal("PublicationOnly".AsSpan(), result); - - Assert.True(ToStringEnum(1).TryFormat(result, out charsWritten, format: null, provider: null)); - Assert.Equal(15, charsWritten); - Assert.Equal("PublicationOnly".AsSpan(), result); - - Assert.True(DomainObjectSerializer.Deserialize(-1).TryFormat(result, out charsWritten, format: null, provider: null)); - Assert.Equal("-1", result[..2]); - } - - [Fact] - public void UtfSpanFormattableTryFormat_InAllScenarios_ShouldReturnExpectedResult() - { - Span result = stackalloc byte[15]; - - Assert.True(ToIntEnum(1).TryFormat(result, out var bytesWritten, format: null, provider: null)); - Assert.Equal(15, bytesWritten); - Assert.Equal("PublicationOnly"u8, result); - - Assert.True(ToEnum(1).TryFormat(result, out bytesWritten, format: null, provider: null)); - Assert.Equal(15, bytesWritten); - Assert.Equal("PublicationOnly"u8, result); - - Assert.True(ToStringEnum(1).TryFormat(result, out bytesWritten, format: null, provider: null)); - Assert.Equal(15, bytesWritten); - Assert.Equal("PublicationOnly"u8, result); - - Assert.True(DomainObjectSerializer.Deserialize(-1).TryFormat(result, out bytesWritten, format: null, provider: null)); - Assert.Equal("-1"u8, result[..2]); - } - - [Fact] - public void ParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() - { - var input = "publicationonly"; - - Assert.True(Enum.TryParse(input, provider: null, out var result1)); - Assert.Equal((LazyThreadSafetyMode)1, result1.Value); - Assert.Equal(result1, Enum.Parse(input, provider: null)); - - Assert.True(IntEnum.TryParse(input, provider: null, out var result2)); - Assert.Equal((LazyThreadSafetyMode)1, result2.Value); - Assert.Equal(result2, IntEnum.Parse(input, provider: null)); - - Assert.True(StringEnum.TryParse(input, provider: null, out var result3)); - Assert.Equal((LazyThreadSafetyMode)1, result3.Value); - Assert.Equal(result3, StringEnum.Parse(input, provider: null)); - } - - [Fact] - public void SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() - { - var input = "publicationonly".AsSpan(); - - Assert.True(Enum.TryParse(input, provider: null, out var result1)); - Assert.Equal((LazyThreadSafetyMode)1, result1.Value); - Assert.Equal(result1, Enum.Parse(input, provider: null)); - - Assert.True(IntEnum.TryParse(input, provider: null, out var result2)); - Assert.Equal((LazyThreadSafetyMode)1, result2.Value); - Assert.Equal(result2, IntEnum.Parse(input, provider: null)); - - Assert.True(StringEnum.TryParse(input, provider: null, out var result3)); - Assert.Equal((LazyThreadSafetyMode)1, result3.Value); - Assert.Equal(result3, StringEnum.Parse(input, provider: null)); - } - - [Fact] - public void Utf8SpanParsableTryParseAndParse_InAllScenarios_ShouldReturnExpectedResult() - { - var input = "publicationonly"u8; - - Assert.True(Enum.TryParse(input, provider: null, out var result1)); - Assert.Equal((LazyThreadSafetyMode)1, result1.Value); - Assert.Equal(result1, Enum.Parse(input, provider: null)); - - Assert.True(IntEnum.TryParse(input, provider: null, out var result2)); - Assert.Equal((LazyThreadSafetyMode)1, result2.Value); - Assert.Equal(result2, IntEnum.Parse(input, provider: null)); - - Assert.True(StringEnum.TryParse(input, provider: null, out var result3)); - Assert.Equal((LazyThreadSafetyMode)1, result3.Value); - Assert.Equal(result3, StringEnum.Parse(input, provider: null)); - } - - [Fact] - public void ParsabilityAndFormattability_InAllScenarios_ShouldBeGeneratedAccordingToTransitiveAvailability() - { - var interfaces = typeof(Enum).GetInterfaces(); - Assert.Contains(interfaces, interf => interf.Name == "ISpanFormattable"); - Assert.Contains(interfaces, interf => interf.Name == "ISpanParsable`1"); - Assert.Contains(interfaces, interf => interf.Name == "IUtf8SpanFormattable"); - Assert.Contains(interfaces, interf => interf.Name == "IUtf8SpanParsable`1"); - - interfaces = typeof(StringEnum).GetInterfaces(); - Assert.Contains(interfaces, interf => interf.Name == "ISpanFormattable"); - Assert.Contains(interfaces, interf => interf.Name == "ISpanParsable`1"); - Assert.Contains(interfaces, interf => interf.Name == "IUtf8SpanFormattable"); - Assert.Contains(interfaces, interf => interf.Name == "IUtf8SpanParsable`1"); + Assert.Throws(() => DefinedEnum.ThrowUndefinedInput((LazyThreadSafetyMode)999, errorState: null)); } } diff --git a/DomainModeling.Tests/Enums/EnumExtensionsTests.cs b/DomainModeling.Tests/Enums/InternalEnumExtensionsTests.cs similarity index 97% rename from DomainModeling.Tests/Enums/EnumExtensionsTests.cs rename to DomainModeling.Tests/Enums/InternalEnumExtensionsTests.cs index 4cb8700..3d17606 100644 --- a/DomainModeling.Tests/Enums/EnumExtensionsTests.cs +++ b/DomainModeling.Tests/Enums/InternalEnumExtensionsTests.cs @@ -4,7 +4,7 @@ namespace Architect.DomainModeling.Tests.Enums; -public class EnumExtensionsTests +public class InternalEnumExtensionsTests { private enum ByteEnum : byte { diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index a341f96..3b90eb7 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -64,7 +64,7 @@ Performance: Misc: - Semi-breaking: IFormattable & co for string wrappers have stopped treating null strings as "", which covered up mistakes instead of revealing them. - Semi-breaking: IIdentity now implements IWrapperValueObject. -- Feature: DefinedEnum<TEnum> wrapper. +- Feature: Analyzer and extensions for defined enums. - Feature: Non-generic Wrapper/Identity interfaces. - Feature: DummyBuilder records clone on each step, for reuse. - Feature: Analyzer warns when '==' or similar operator implicitly casts some IValueObject to something else. Avoids accidentally comparing unrelated types. diff --git a/DomainModeling/Enums/DefinedEnum.cs b/DomainModeling/Enums/DefinedEnum.cs index 006f1e6..85bb940 100644 --- a/DomainModeling/Enums/DefinedEnum.cs +++ b/DomainModeling/Enums/DefinedEnum.cs @@ -1,608 +1,77 @@ -using System.Buffers; -using System.Collections.Frozen; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Unicode; -using Architect.DomainModeling.Conversions; using Architect.DomainModeling.Enums; #pragma warning disable IDE0130 // Namespace does not match folder structure namespace Architect.DomainModeling; /// -/// Provides utilities and configuration for and . +/// Provides utilities and configuration to work with defined enum values, i.e. values that are defined for their enum type. /// public static class DefinedEnum { /// /// - /// The factory used to produce an exception whenever a or is attempted to be constructed based on null. + /// The factory used to produce an exception whenever or is called on an undefined value. /// /// - /// Receives the enum type. + /// Receives the enum type, the numeric value, and the optional error state passed during the validation. /// /// - public static Func? ExceptionFactoryForNullInput { get; set; } + public static Func? ExceptionFactoryForUndefinedInput { get; set; } + /// /// - /// The factory used to produce an exception whenever a or is attempted to be constructed based on an undefined value for its type. - /// - /// - /// For an enum with the , any combination of bits used in its defined values is permitted, i.e. this is only used whenever a value includes any undefined bit. + /// Throws the configured exception for enum value of type being undefined for its type. /// /// - /// Receives the enum type and the numeric value. + /// This method can be used to produce the same effect as without calling it, such as from a mapper. /// /// - public static Func? ExceptionFactoryForUndefinedInput { get; set; } - - /// - /// Constructs a new , with type inference. - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static DefinedEnum Create(TEnum enumValue) - where TEnum : unmanaged, Enum + /// The enum's type. + /// The enum's numeric value, such as (Int128)(int)HttpStatusCode.OK. + /// An optional error state to be passed to . + [DoesNotReturn] + public static void ThrowUndefinedInput(Type enumType, Int128 numericValue, string? errorState = null) { - return new DefinedEnum(enumValue); + throw ExceptionFactoryForUndefinedInput?.Invoke(enumType, numericValue, errorState) ?? new ArgumentException($"Only recognized {enumType.Name} values are permitted."); } /// /// - /// Constructs a new , with type inference. + /// Throws the configured exception for enum value being undefined for its type. /// /// - /// Accepts a nullable parameter, but throws for null values. - /// For example, this is useful for a mandatory request input where omission must lead to rejection. + /// This method can be used to produce the same effect as without calling it, such as from a mapper. /// /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static DefinedEnum Create([DisallowNull] TEnum? enumValue) - where TEnum : unmanaged, Enum - { - return new DefinedEnum(enumValue); - } - + /// The enum's value. + /// An optional error state to be passed to . [DoesNotReturn] - internal static object ThrowNullInput(Type enumType, string paramName) - { - throw ExceptionFactoryForNullInput?.Invoke(enumType) ?? new ArgumentNullException(paramName); - } - - [DoesNotReturn] - internal static void ThrowUndefinedInput(Type enumType, Int128 numericValue) + public static void ThrowUndefinedInput(TEnum value, string? errorState = null) + where TEnum : unmanaged, Enum { - throw ExceptionFactoryForUndefinedInput?.Invoke(enumType, numericValue) ?? new ArgumentException($"Only recognized {enumType.Name} values are permitted."); + ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue(), errorState); } -} - -/// -/// -/// An enum of type whose value is one of the defined values for the enum type. -/// -/// -/// If has the , any combination of bits used in its defined values is permitted. -/// -/// -/// This type additionally improves the performance of certain operations, such as . -/// -/// -/// Values can be created with type inference using , or converted implicitly from a defined constant of . -/// -/// -/// An analyzer prevents wrapper value object structs like this from skipping validation via the 'default' keyword. -/// -/// -/// The type of the represented enum. -[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] -[DebuggerDisplay("{ToString()}")] -[WrapperValueObject] -public readonly struct DefinedEnum : - IWrapperValueObject, - IEquatable>, - IComparable>, - ISpanFormattable, - ISpanParsable>, - IUtf8SpanFormattable, - IUtf8SpanParsable>, - ICoreValueWrapper, TEnum> - where TEnum : unmanaged, Enum -{ - private static readonly FrozenDictionary DefinedValueNamePairs = Enum.GetValues() - .Distinct() // Multiple members may be defined with the same numeric value, but both value and Enum.GetName(value) will be identical between duplicates - .ToFrozenDictionary(value => value, value => Enum.GetName(value) ?? value.ToString()); - - private static readonly bool IsFlags = typeof(TEnum).IsDefined(typeof(FlagsAttribute), inherit: false); - private static readonly ulong AllFlags = DefinedValueNamePairs.Aggregate(0UL, (current, next) => current | next.Key.GetBinaryValue()); /// /// - /// Contains an arbitrary undefined value for enum type . + /// Contains an undefined value for . /// /// - /// The value may change between compilations. + /// This type cannot be constructed for a type parameter where all values are defined, such as with a byte-backed enum that defines all its 256 possible numbers. /// - /// - /// The value is null in the rare case where defines every possible numeric value for its underlying type, such as with a byte-backed enum that defines all 256 possible numbers. - /// - /// - public static readonly TEnum? UndefinedValue = ~AllFlags is var unusedBits && Unsafe.As(ref unusedBits) is var value && value.GetBinaryValue() != 0UL // Any bits unused? - ? value - : !IsFlags && EnumExtensions.TryGetUndefinedValue(out value) // With all bits used, for non-flags we can still look for an individual unused value, since values are not combined - ? value - : null; - - public TEnum Value - { - get => this._value; - internal init => this._value = value; - } - private readonly TEnum _value; - - /// - /// Throws the same exception as when a was attempted to be constructed with an undefined input value. - /// - [DoesNotReturn] - public static TEnum ThrowUndefinedInput(TEnum value = default) - { - DefinedEnum.ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue()); - return default; - } - - public DefinedEnum(TEnum value) - { - if (IsFlags) - { - if ((AllFlags | value.GetBinaryValue()) != AllFlags) - DefinedEnum.ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue()); - } - else - { - if (!DefinedValueNamePairs.ContainsKey(value)) - DefinedEnum.ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue()); - } - - this.Value = value; - } - - /// - /// Accepts a nullable parameter, but throws for null values. - /// For example, this is useful for a mandatory request input where omission must lead to rejection. - /// - public DefinedEnum([DisallowNull] TEnum? value) - : this(value ?? (TEnum)DefinedEnum.ThrowNullInput(typeof(TEnum), nameof(value))) - { - } - - /// - /// Obsolete: This constructor exists for deserialization purposes only. - /// - [Obsolete("This constructor exists for deserialization purposes only.")] - public DefinedEnum() - { - } - - public override string ToString() - { - return DefinedValueNamePairs.GetValueOrDefault(this.Value) ?? - this.Value.ToString(); // For combined flags, or in case someone created an undefined enum after all, such as using default(T) - } - - public string ToString(string? format) - { - return format is null - ? this.ToString() // Efficient - : this.Value.ToString(format); - } - - public override int GetHashCode() - { - return this.Value.GetHashCode(); - } - - public override bool Equals(object? other) - { - return other is DefinedEnum otherValue && this.Equals(otherValue); - } - - public bool Equals(DefinedEnum other) - { - return EqualityComparer.Default.Equals(this.Value, other.Value); - } - - public int CompareTo(DefinedEnum other) - { - return Comparer.Default.Compare(this.Value, other.Value); - } - - public static bool operator ==(DefinedEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left.Value, right.Value); - public static bool operator !=(DefinedEnum left, DefinedEnum right) => !(left == right); - public static bool operator ==(DefinedEnum left, TEnum right) => EqualityComparer.Default.Equals(left.Value, right); - public static bool operator !=(DefinedEnum left, TEnum right) => !(left == right); - public static bool operator ==(TEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left, right.Value); - public static bool operator !=(TEnum left, DefinedEnum right) => !(left == right); - - public static bool operator >(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) > 0; - public static bool operator <=(DefinedEnum left, DefinedEnum right) => !(left > right); - public static bool operator >(DefinedEnum left, TEnum right) => Comparer.Default.Compare(left.Value, right) > 0; - public static bool operator <=(DefinedEnum left, TEnum right) => !(left > right); - public static bool operator >(TEnum left, DefinedEnum right) => Comparer.Default.Compare(left, right.Value) > 0; - public static bool operator <=(TEnum left, DefinedEnum right) => !(left > right); - - public static bool operator <(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) < 0; - public static bool operator >=(DefinedEnum left, DefinedEnum right) => !(left < right); - public static bool operator <(DefinedEnum left, TEnum right) => Comparer.Default.Compare(left.Value, right) < 0; - public static bool operator >=(DefinedEnum left, TEnum right) => !(left < right); - public static bool operator <(TEnum left, DefinedEnum right) => Comparer.Default.Compare(left, right.Value) < 0; - public static bool operator >=(TEnum left, DefinedEnum right) => !(left < right); - - /// - /// Constrained to defined constants by analyzer. - /// - public static implicit operator DefinedEnum(TEnum value) => new DefinedEnum(value); - public static implicit operator TEnum(DefinedEnum instance) => instance.Value; - - /// - /// Constrained to defined constants by analyzer. - /// - [return: NotNullIfNotNull(nameof(value))] - public static implicit operator DefinedEnum?(TEnum? value) => value is { } actual ? new DefinedEnum(actual) : (DefinedEnum?)null; - [return: NotNullIfNotNull(nameof(instance))] - public static implicit operator TEnum?(DefinedEnum? instance) => instance?.Value; - - public static explicit operator Int128(DefinedEnum instance) => instance.Value.GetNumericValue(); - - [return: NotNullIfNotNull(nameof(instance))] - public static explicit operator Int128?(DefinedEnum? instance) => instance?.Value.GetNumericValue(); - - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - public static implicit operator DefinedEnum(DefinedEnum instance) => default(DefinedEnum) with { Value = instance.Value }; - - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - public static implicit operator DefinedEnum?(DefinedEnum? instance) => instance?.Value is { } value ? default(DefinedEnum) with { Value = value } : null; - - #region Wrapping & Serialization - - static DefinedEnum IValueWrapper, TEnum>.Create(TEnum value) - { - return new DefinedEnum(value); - } - - TEnum IValueWrapper, TEnum>.Serialize() - { - return this.Value; - } - - static DefinedEnum IValueWrapper, TEnum>.Deserialize(TEnum value) - { - return default(DefinedEnum) with { Value = value }; - } - - #endregion - - #region Formatting & Parsing - - /// Obsolete and ignored for enums. - public string ToString(string? format, IFormatProvider? formatProvider) => - this.ToString(format); - - /// Obsolete and ignored for enums. - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => - format.IsEmpty && this.ToString() is { } value // Efficient - ? (charsWritten = value.TryCopyTo(destination) ? value.Length : 0) != 0 - : Enum.TryFormat(this.Value, destination, out charsWritten, format); - - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => - Enum.TryParse(s, ignoreCase: true, out var value) - ? (result = (DefinedEnum)value) is var _ - : !((result = default) is var _); - - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => - Enum.TryParse(s, ignoreCase: true, out var value) - ? (result = (DefinedEnum)value) is var _ - : !((result = default) is var _); - - public static DefinedEnum Parse(string s, IFormatProvider? provider) => - (DefinedEnum)Enum.Parse(s, ignoreCase: true); - - public static DefinedEnum Parse(ReadOnlySpan s, IFormatProvider? provider) => - (DefinedEnum)Enum.Parse(s, ignoreCase: true); - - /// Obsolete and ignored for enums. - public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) => - format.IsEmpty && this.ToString() is { } value // Efficient - ? Utf8.FromUtf16(value, utf8Destination, charsRead: out _, bytesWritten: out bytesWritten) == OperationStatus.Done - : Utf8.FromUtf16(this.Format(stackalloc char[64], format, provider), utf8Destination, charsRead: out _, bytesWritten: out bytesWritten) == OperationStatus.Done; // Delegates to char overload - - public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => - utf8Text.Length <= 2048 && stackalloc char[utf8Text.Length] is var chars && Utf8.ToUtf16(utf8Text, chars, bytesRead: out _, charsWritten: out var charsWritten) == OperationStatus.Done - ? TryParse(chars[..charsWritten], provider, out result) - : !((result = default) is var _); - - public static DefinedEnum Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => - utf8Text.Length <= 2048 && stackalloc char[utf8Text.Length] is var chars && Utf8.ToUtf16(utf8Text, chars, bytesRead: out _, charsWritten: out var charsWritten) == OperationStatus.Done - ? Parse(chars[..charsWritten], provider) - : Parse(Encoding.UTF8.GetString(utf8Text), provider); // Throws the appropriate exception - - #endregion -} - -/// -/// -/// An enum of type whose value is one of the defined values for the enum type. -/// -/// -/// If has the , any combination of bits used in its defined values is permitted. -/// -/// -/// The second type parameter indicates whether the enum should be represented as or numerically in contexts where the enum itself cannot be used. -/// Examples include JSON serialization and Entity Framework mapping. -/// -/// -/// This type additionally improves the performance of certain operations, such as . -/// -/// -/// Values can be created with type inference using , or converted implicitly from a defined constant of . -/// They can also be received as parameters of type , without the need to repeat the type parameter for the primitive. -/// -/// -/// An analyzer prevents wrapper value object structs like this from skipping validation via the 'default' keyword. -/// -/// -/// The type of the represented enum. -/// The underlying representation, either or the enum's exact underlying integral type. -[SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] -[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", - Justification = "We want serialization configured irrespectve of whether the developer uses trimming.")] -[System.Text.Json.Serialization.JsonConverter(typeof(EnumJsonConverterFactory))] -[Newtonsoft.Json.JsonConverter(typeof(EnumNewtonsoftJsonConverterFactory))] -[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] -[DebuggerDisplay("{ToString()}")] -[WrapperValueObject] -public readonly struct DefinedEnum : - IWrapperValueObject, - IEquatable>, IEquatable>, - IComparable>, IComparable>, - ISpanFormattable, - ISpanParsable>, - IUtf8SpanFormattable, - IUtf8SpanParsable>, - ICoreValueWrapper, TPrimitive> - where TEnum : unmanaged, Enum - where TPrimitive : IEquatable, IComparable, ISpanParsable, IConvertible // string or TEnumUnderlying -{ - public TEnum Value - { - get => this._value; - internal init => this._value = value; - } - private readonly TEnum _value; - - static DefinedEnum() - { - if (typeof(TPrimitive) != typeof(string) && typeof(TPrimitive) != Enum.GetUnderlyingType(typeof(TEnum))) - throw new NotSupportedException($"{nameof(DefinedEnum)}<{nameof(TEnum)}, {nameof(TPrimitive)}> requires {nameof(TPrimitive)} to be either string or the enum's underlying integer type."); - } - - public DefinedEnum(TEnum value) - { - this.Value = new DefinedEnum(value).Value; - } - - /// - /// Accepts a nullable parameter, but throws for null values. - /// For example, this is useful for a mandatory request input where omission must lead to rejection. - /// - public DefinedEnum([DisallowNull] TEnum? value) - : this(value ?? (TEnum)DefinedEnum.ThrowNullInput(typeof(TEnum), nameof(value))) - { - } - - /// - /// Obsolete: This constructor exists for deserialization purposes only. /// - [Obsolete("This constructor exists for deserialization purposes only.")] - public DefinedEnum() - { - } - - public override string ToString() - { - return (default(DefinedEnum) with { Value = this.Value }).ToString(); - } - - public string ToString(string? format) - { - return (default(DefinedEnum) with { Value = this.Value }).ToString(format); - } - - public override int GetHashCode() - { - return this.Value.GetHashCode(); - } - - public override bool Equals(object? other) - { - return (other is DefinedEnum otherValue && this.Equals(otherValue)) || - (other is DefinedEnum genericOtherValue && this.Equals(genericOtherValue)); - } - - public bool Equals(DefinedEnum other) - { - return EqualityComparer.Default.Equals(this.Value, other.Value); - } - - public bool Equals(DefinedEnum other) - { - return EqualityComparer.Default.Equals(this.Value, other.Value); - } - - public int CompareTo(DefinedEnum other) - { - return Comparer.Default.Compare(this.Value, other.Value); - } - - public int CompareTo(DefinedEnum other) - { - return Comparer.Default.Compare(this.Value, other.Value); - } - - public static bool operator ==(DefinedEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left.Value, right.Value); - public static bool operator !=(DefinedEnum left, DefinedEnum right) => !(left == right); - public static bool operator ==(DefinedEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left.Value, right.Value); - public static bool operator !=(DefinedEnum left, DefinedEnum right) => !(left == right); - public static bool operator ==(DefinedEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left.Value, right.Value); - public static bool operator !=(DefinedEnum left, DefinedEnum right) => !(left == right); - public static bool operator ==(DefinedEnum left, TEnum right) => EqualityComparer.Default.Equals(left.Value, right); - public static bool operator !=(DefinedEnum left, TEnum right) => !(left == right); - public static bool operator ==(TEnum left, DefinedEnum right) => EqualityComparer.Default.Equals(left, right.Value); - public static bool operator !=(TEnum left, DefinedEnum right) => !(left == right); - - public static bool operator >(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) > 0; - public static bool operator <=(DefinedEnum left, DefinedEnum right) => !(left > right); - public static bool operator >(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) > 0; - public static bool operator <=(DefinedEnum left, DefinedEnum right) => !(left > right); - public static bool operator >(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) > 0; - public static bool operator <=(DefinedEnum left, DefinedEnum right) => !(left > right); - public static bool operator >(DefinedEnum left, TEnum right) => Comparer.Default.Compare(left.Value, right) > 0; - public static bool operator <=(DefinedEnum left, TEnum right) => !(left > right); - public static bool operator >(TEnum left, DefinedEnum right) => Comparer.Default.Compare(left, right.Value) > 0; - public static bool operator <=(TEnum left, DefinedEnum right) => !(left > right); - - public static bool operator <(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) < 0; - public static bool operator >=(DefinedEnum left, DefinedEnum right) => !(left < right); - public static bool operator <(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) < 0; - public static bool operator >=(DefinedEnum left, DefinedEnum right) => !(left < right); - public static bool operator <(DefinedEnum left, DefinedEnum right) => Comparer.Default.Compare(left.Value, right.Value) < 0; - public static bool operator >=(DefinedEnum left, DefinedEnum right) => !(left < right); - public static bool operator <(DefinedEnum left, TEnum right) => Comparer.Default.Compare(left.Value, right) < 0; - public static bool operator >=(DefinedEnum left, TEnum right) => !(left < right); - public static bool operator <(TEnum left, DefinedEnum right) => Comparer.Default.Compare(left, right.Value) < 0; - public static bool operator >=(TEnum left, DefinedEnum right) => !(left < right); - - /// - /// Constrained to defined constants by analyzer. - /// - public static implicit operator DefinedEnum(TEnum value) => new DefinedEnum(value); - public static implicit operator TEnum(DefinedEnum instance) => instance.Value; - - /// - /// Constrained to defined constants by analyzer. - /// - [return: NotNullIfNotNull(nameof(value))] - public static implicit operator DefinedEnum?(TEnum? value) => value is { } actual ? new DefinedEnum(actual) : (DefinedEnum?)null; - [return: NotNullIfNotNull(nameof(instance))] - public static implicit operator TEnum?(DefinedEnum? instance) => instance?.Value; - - public static explicit operator Int128(DefinedEnum instance) => instance.Value.GetNumericValue(); - - [return: NotNullIfNotNull(nameof(instance))] - public static explicit operator Int128?(DefinedEnum? instance) => instance?.Value.GetNumericValue(); - - #region Wrapping & Serialization - - TPrimitive IValueWrapper, TPrimitive>.Value => typeof(TPrimitive) == typeof(string) - ? (TPrimitive)(object)this.ToString() // Efficient - : Unsafe.As(ref Unsafe.AsRef(in this._value)); - - static DefinedEnum IValueWrapper, TPrimitive>.Create(TPrimitive value) - { - return typeof(TPrimitive) == typeof(string) - ? Parse((string)(object)value, provider: null) - : new DefinedEnum(Unsafe.As(ref value)); - } - - TPrimitive IValueWrapper, TPrimitive>.Serialize() + public static class UndefinedValues + where TEnum : unmanaged, Enum { - return typeof(TPrimitive) == typeof(string) - ? (TPrimitive)(object)this.ToString() // Efficient - : Unsafe.As(ref Unsafe.AsRef(in this._value)); - } + private static readonly bool IsFlags = typeof(TEnum).IsDefined(typeof(FlagsAttribute), inherit: false); + internal static readonly ulong AllFlags = Enum.GetValues().Aggregate(0UL, (current, next) => current | next.GetBinaryValue()); - static DefinedEnum IValueWrapper, TPrimitive>.Deserialize(TPrimitive value) - { - return default(DefinedEnum) with - { - Value = typeof(TPrimitive) == typeof(string) - ? TryParse((string)(object)value, provider: null, out var result) ? result : (DefinedEnum.UndefinedValue ?? default) - : Unsafe.As(ref value), - }; + public static TEnum UndefinedValue { get; } = ~AllFlags is var unusedBits && Unsafe.As(ref unusedBits) is var value && value.GetBinaryValue() != 0UL // Any bits unused? + ? value + : !IsFlags && InternalEnumExtensions.TryGetUndefinedValue(out value) // With all bits used, for non-flags we can still look for an individual unused value, since values are not combined + ? value + : throw new NotSupportedException($"Type {typeof(TEnum).Name} does not leave any possible values undefined (or flag bits unused)."); } - - #endregion - - #region Formatting & Parsing - - /// Obsolete and ignored for enums. - public string ToString(string? format, IFormatProvider? formatProvider) => - (default(DefinedEnum) with { Value = this.Value }).ToString(format, formatProvider); - - /// Obsolete and ignored for enums. - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => - (default(DefinedEnum) with { Value = this.Value }).TryFormat(destination, out charsWritten, format, provider); - - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => - DefinedEnum.TryParse(s, provider, out var intermediateResult) - ? (result = default(DefinedEnum) with { Value = intermediateResult.Value }) is var _ - : !((result = default) is var _); - - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => - DefinedEnum.TryParse(s, provider, out var intermediateResult) - ? (result = default(DefinedEnum) with { Value = intermediateResult.Value }) is var _ - : !((result = default) is var _); - - public static DefinedEnum Parse(string s, IFormatProvider? provider) => - default(DefinedEnum) with { Value = DefinedEnum.Parse(s, provider).Value }; - - public static DefinedEnum Parse(ReadOnlySpan s, IFormatProvider? provider) => - default(DefinedEnum) with { Value = DefinedEnum.Parse(s, provider).Value }; - - /// Obsolete and ignored for enums. - public bool TryFormat(Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) => - (default(DefinedEnum) with { Value = this.Value }).TryFormat(utf8Destination, out bytesWritten, format, provider); - - public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [MaybeNullWhen(false)] out DefinedEnum result) => - DefinedEnum.TryParse(utf8Text, provider, out var intermediateResult) - ? (result = default(DefinedEnum) with { Value = intermediateResult.Value }) is var _ - : !((result = default) is var _); - - public static DefinedEnum Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => - default(DefinedEnum) with { Value = DefinedEnum.Parse(utf8Text, provider).Value }; - - #endregion } diff --git a/DomainModeling/Enums/EnumExtensions.cs b/DomainModeling/Enums/EnumExtensions.cs index b7a75d9..270943b 100644 --- a/DomainModeling/Enums/EnumExtensions.cs +++ b/DomainModeling/Enums/EnumExtensions.cs @@ -1,136 +1,90 @@ -using System.Runtime.CompilerServices; +using Architect.DomainModeling.Enums; -namespace Architect.DomainModeling.Enums; +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace Architect.DomainModeling; -internal static class EnumExtensions +/// +/// Provides enum extensions for domain modeling. +/// +public static class EnumExtensions { - private static readonly byte DefaultUndefinedValue = 191; // Greatest prime under 3/4 of Byte.MaxValue - private static readonly ushort FallbackUndefinedValue = 49139; // Greatest prime under 3/4 of UInt16.MaxValue - /// /// - /// Attempts to return one of a small set of predefined values if one is undefined for . + /// Validates that is a defined value, throwing otherwise. /// /// - /// Does not accounts for the . + /// The potential exception can be globally configured using . /// /// - public static bool TryGetUndefinedValueFast(out TEnum value) + public static TEnum AsDefined(this TEnum value, string? errorState = null) where TEnum : unmanaged, Enum { - var defaultUndefined = Unsafe.As(ref Unsafe.AsRef(in DefaultUndefinedValue)); - if (!Enum.IsDefined(defaultUndefined)) - { - value = defaultUndefined; - return true; - } - - var fallbackUndefined = Unsafe.As(ref Unsafe.AsRef(in FallbackUndefinedValue)); - if (Unsafe.SizeOf() >= 2 && !Enum.IsDefined(fallbackUndefined)) - { - value = fallbackUndefined; - return true; - } + if (!Enum.IsDefined(value)) + DefinedEnum.ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue(), errorState); - value = default; - return false; + return value; } /// /// - /// Attempts to find an undefined value for . + /// Validates that is either null or a defined value, throwing otherwise. /// /// - /// Does not accounts for the . + /// The potential exception can be globally configured using . /// /// - public static bool TryGetUndefinedValue(out TEnum value) + public static TEnum? AsDefined(this TEnum? value, string? errorState = null) where TEnum : unmanaged, Enum { - if (TryGetUndefinedValueFast(out value)) - return true; - - var values = Enum.GetValues(); - System.Diagnostics.Debug.Assert(values.Select(GetBinaryValue).Order().SequenceEqual(values.Select(GetBinaryValue)), "Enum.GetValues() was expected to return elements in binary order."); - - // If we do not end with the binary maximum, then use that - var enumBinaryMax = ~0UL >> (64 - 8 * Unsafe.SizeOf()); // E.g. 64-0 bits for ulong/long, 64-32 for uint/int, and so on - if (values.Length == 0 || values[^1].GetBinaryValue() < enumBinaryMax) - { - value = Unsafe.As(ref enumBinaryMax); - return true; - } - - // If we do not start with the default, then use that - ulong previousValue; - if ((previousValue = values[0].GetBinaryValue()) != 0UL) - { - value = default; - return true; - } - - foreach (var definedValue in values.Skip(1)) - { - // If there is a gap between the current and previous item - var currentValue = definedValue.GetBinaryValue(); - if (currentValue > previousValue + 1) - { - previousValue++; - value = Unsafe.As(ref previousValue); - return true; - } - previousValue = currentValue; - } - - value = default; - return false; + return value is TEnum actual ? AsDefined(actual, errorState) : null; } /// - /// Returns the numeric value of the given . + /// + /// Validates that is a valid combination of bits used in the defined values for , throwing otherwise. + /// + /// + /// The potential exception can be globally configured using . + /// /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Int128 GetNumericValue(this T enumValue) - where T : unmanaged, Enum + public static TEnum AsDefinedFlags(this TEnum value, string? errorState = null) + where TEnum : unmanaged, Enum { - // Optimized by JIT, as Type.GetTypeCode(T) is treated as a constant - return Type.GetTypeCode(typeof(T)) switch - { - TypeCode.Byte => (Int128)Unsafe.As(ref enumValue), - TypeCode.SByte => (Int128)Unsafe.As(ref enumValue), - TypeCode.Int16 => (Int128)Unsafe.As(ref enumValue), - TypeCode.UInt16 => (Int128)Unsafe.As(ref enumValue), - TypeCode.Int32 => (Int128)Unsafe.As(ref enumValue), - TypeCode.UInt32 => (Int128)Unsafe.As(ref enumValue), - TypeCode.Int64 => (Int128)Unsafe.As(ref enumValue), - TypeCode.UInt64 => (Int128)Unsafe.As(ref enumValue), - _ => default, - }; + if ((DefinedEnum.UndefinedValues.AllFlags | value.GetBinaryValue()) != DefinedEnum.UndefinedValues.AllFlags) + DefinedEnum.ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue(), errorState); + + return value; } /// /// - /// Returns the binary value of the given , contained in a . + /// Validates that is either null or a valid combination of bits used in the defined values for , throwing otherwise. /// /// - /// The original value's bytes can be retrieved by doing a cast or to the original enum or underlying type. + /// The potential exception can be globally configured using . /// /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static ulong GetBinaryValue(this T enumValue) - where T : unmanaged, Enum + public static TEnum? AsDefinedFlags(this TEnum? value, string? errorState = null) + where TEnum : unmanaged, Enum { - var result = 0UL; + return value is TEnum actual ? AsDefinedFlags(actual, errorState) : null; + } - // Since the actual value may be smaller than ulong's 8 bytes, we must align to the least significant byte - // This way, casting the ulong back to the original type gets back the exact original bytes - // On little-endian, that means aligning to the left of the bytes - // On big-endian, that means aligning to the right of the bytes - if (BitConverter.IsLittleEndian) - Unsafe.WriteUnaligned(ref Unsafe.As(ref result), enumValue); - else - Unsafe.WriteUnaligned(ref Unsafe.Add(ref Unsafe.As(ref result), sizeof(ulong) - Unsafe.SizeOf()), enumValue); + /// + /// Clarifies the deliberate intent to assign without validating that it is a defined value. + /// + public static TEnum AsUnvalidated(this TEnum value) + where TEnum : unmanaged, Enum + { + return value; + } - return result; + /// + /// Clarifies the deliberate intent to assign without validating that it is (null or) a defined value. + /// + public static TEnum? AsUnvalidated(this TEnum? value) + where TEnum : unmanaged, Enum + { + return value is TEnum actual ? AsUnvalidated(actual) : null; } } diff --git a/DomainModeling/Enums/EnumJsonConverter.cs b/DomainModeling/Enums/EnumJsonConverter.cs deleted file mode 100644 index 64c5074..0000000 --- a/DomainModeling/Enums/EnumJsonConverter.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Text.Json.Serialization; -using Architect.DomainModeling.Conversions; - -namespace Architect.DomainModeling.Enums; - -/// -/// A factory to produce a generic System.Text JSON converter for enum wrapper types, which serializes like the wrapped value itself. -/// -[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] -[UnconditionalSuppressMessage( - "Trimming", "IL2046:All interface implementations and method overrides must have annotations matching the interface or overridden virtual method 'RequiresUnreferencedCodeAttribute' annotations", - Justification = "Unlike our base, we prefer to annotate the methods instead of the type, to avoid a warning merely because source-generated code includes a JSON converter." -)] -internal sealed class EnumJsonConverterFactory : JsonConverterFactory -{ - private const string RequiresUnreferencedCodeMessage = "Serialization requires unreferenced code."; - private const string RequiresDynamicCodeMessage = "Serialization requires dynamic code."; - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public override bool CanConvert(Type typeToConvert) - { - return typeToConvert.IsConstructedGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(DefinedEnum<,>); - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - [UnconditionalSuppressMessage( - "Trimming", "IL2055:Either the type on which the MakeGenericType is called can't be statically determined, or the type parameters to be used for generic arguments can't be statically determined", - Justification = "Both the converted and converter types are marked with DynamicallyAccessedMemberTypes.All." - )] - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) - { - return Activator.CreateInstance(typeof(EnumJsonConverter<,>).MakeGenericType(typeToConvert, typeToConvert.GenericTypeArguments[1])) as JsonConverter; - } -} - -/// -/// A generic System.Text JSON converter for enum wrapper types, which serializes like the wrapped value itself. -/// -[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] -[UnconditionalSuppressMessage( - "Trimming", "IL2046:All interface implementations and method overrides must have annotations matching the interface or overridden virtual method 'RequiresUnreferencedCodeAttribute' annotations", - Justification = "Unlike our base, we prefer to annotate the methods instead of the type, to avoid a warning merely because source-generated code includes a JSON converter." -)] -internal sealed class EnumJsonConverter< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, - TPrimitive> - : JsonConverter - where TWrapper : IValueWrapper, ISpanParsable -{ - private const string RequiresUnreferencedCodeMessage = "Serialization requires unreferenced code."; - private const string RequiresDynamicCodeMessage = "Serialization requires dynamic code."; - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public override TWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = JsonSerializer.Deserialize(ref reader, options)!; - return DomainObjectSerializer.Deserialize(value); - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public override void Write(Utf8JsonWriter writer, TWrapper value, JsonSerializerOptions options) - { - var serializedValue = DomainObjectSerializer.Serialize(value); - JsonSerializer.Serialize(writer, serializedValue, options); - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public override TWrapper ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var value = ((JsonConverter)options.GetConverter(typeof(TPrimitive))).ReadAsPropertyName(ref reader, typeToConvert, options)!; - return DomainObjectSerializer.Deserialize(value); - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - [RequiresDynamicCode(RequiresDynamicCodeMessage)] - public override void WriteAsPropertyName(Utf8JsonWriter writer, TWrapper value, JsonSerializerOptions options) - { - var serializedValue = DomainObjectSerializer.Serialize(value)!; - ((JsonConverter)options.GetConverter(typeof(TPrimitive))).WriteAsPropertyName( - writer, - serializedValue, - options); - } -} diff --git a/DomainModeling/Enums/EnumNewtonsoftJsonConverter.cs b/DomainModeling/Enums/EnumNewtonsoftJsonConverter.cs deleted file mode 100644 index 4e19484..0000000 --- a/DomainModeling/Enums/EnumNewtonsoftJsonConverter.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using Architect.DomainModeling.Conversions; -using Newtonsoft.Json; - -namespace Architect.DomainModeling.Enums; - -/// -/// A factory to produce a generic Newtonsoft JSON converter for enum wrapper types, which serializes like the wrapped value itself. -/// -[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] -internal sealed class EnumNewtonsoftJsonConverterFactory : JsonConverter -{ - private static readonly ConcurrentDictionary ConvertersPerType = new(concurrencyLevel: 1, capacity: 11); - - public override bool CanConvert(Type objectType) - { - return objectType.IsConstructedGenericType && objectType.GetGenericTypeDefinition() == typeof(DefinedEnum<,>); - } - - [UnconditionalSuppressMessage( - "Trimming", "IL2055:Either the type on which the MakeGenericType is called can't be statically determined, or the type parameters to be used for generic arguments can't be statically determined", - Justification = "Both the converted and converter types are marked with DynamicallyAccessedMemberTypes.All." - )] - public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) - { - var nullableUnderlyingType = Nullable.GetUnderlyingType(objectType); - if (nullableUnderlyingType is not null) - { - if (reader.Value is null) - return null; - objectType = nullableUnderlyingType; - } - - return ConvertersPerType.GetOrAdd(objectType, type => (JsonConverter)Activator.CreateInstance(typeof(EnumNewtonsoftJsonConverter<,>).MakeGenericType(type, type.GenericTypeArguments[1]))!) - .ReadJson(reader, objectType, existingValue, serializer); - } - - [UnconditionalSuppressMessage( - "Trimming", "IL2055:Either the type on which the MakeGenericType is called can't be statically determined, or the type parameters to be used for generic arguments can't be statically determined", - Justification = "Both the converted and converter types are marked with DynamicallyAccessedMemberTypes.All." - )] - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - if (value?.GetType() is { } type) - ConvertersPerType.GetOrAdd(type, type => (JsonConverter)Activator.CreateInstance(typeof(EnumNewtonsoftJsonConverter<,>).MakeGenericType(type, type.GenericTypeArguments[1]))!) - .WriteJson(writer, value, serializer); - else - serializer.Serialize(writer, null); - } -} - -/// -/// A generic Newtonsoft JSON converter for enum wrapper types, which serializes like the wrapped value itself. -/// -[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] -internal sealed class EnumNewtonsoftJsonConverter< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, - TPrimitive> - : JsonConverter - where TWrapper : IValueWrapper, ISpanParsable -{ - public override TWrapper? ReadJson(JsonReader reader, Type objectType, TWrapper? existingValue, bool hasExistingValue, JsonSerializer serializer) - { - var value = serializer.Deserialize(reader); - return DomainObjectSerializer.Deserialize(value); - } - - public override void WriteJson(JsonWriter writer, TWrapper? value, JsonSerializer serializer) - { - var underlyingValue = value is not TWrapper instance - ? (object?)null - : DomainObjectSerializer.Serialize(instance); - serializer.Serialize(writer, underlyingValue); - } -} diff --git a/DomainModeling/Enums/InternalEnumExtensions.cs b/DomainModeling/Enums/InternalEnumExtensions.cs new file mode 100644 index 0000000..eef8ff7 --- /dev/null +++ b/DomainModeling/Enums/InternalEnumExtensions.cs @@ -0,0 +1,136 @@ +using System.Runtime.CompilerServices; + +namespace Architect.DomainModeling.Enums; + +internal static class InternalEnumExtensions +{ + private static readonly byte DefaultUndefinedValue = 191; // Greatest prime under 3/4 of Byte.MaxValue + private static readonly ushort FallbackUndefinedValue = 49139; // Greatest prime under 3/4 of UInt16.MaxValue + + /// + /// + /// Attempts to return one of a small set of predefined values if one is undefined for . + /// + /// + /// Does not accounts for the . + /// + /// + public static bool TryGetUndefinedValueFast(out TEnum value) + where TEnum : unmanaged, Enum + { + var defaultUndefined = Unsafe.As(ref Unsafe.AsRef(in DefaultUndefinedValue)); + if (!Enum.IsDefined(defaultUndefined)) + { + value = defaultUndefined; + return true; + } + + var fallbackUndefined = Unsafe.As(ref Unsafe.AsRef(in FallbackUndefinedValue)); + if (Unsafe.SizeOf() >= 2 && !Enum.IsDefined(fallbackUndefined)) + { + value = fallbackUndefined; + return true; + } + + value = default; + return false; + } + + /// + /// + /// Attempts to find an undefined value for . + /// + /// + /// Does not accounts for the . + /// + /// + public static bool TryGetUndefinedValue(out TEnum value) + where TEnum : unmanaged, Enum + { + if (TryGetUndefinedValueFast(out value)) + return true; + + var values = Enum.GetValues(); + System.Diagnostics.Debug.Assert(values.Select(GetBinaryValue).Order().SequenceEqual(values.Select(GetBinaryValue)), "Enum.GetValues() was expected to return elements in binary order."); + + // If we do not end with the binary maximum, then use that + var enumBinaryMax = ~0UL >> (64 - 8 * Unsafe.SizeOf()); // E.g. 64-0 bits for ulong/long, 64-32 for uint/int, and so on + if (values.Length == 0 || values[^1].GetBinaryValue() < enumBinaryMax) + { + value = Unsafe.As(ref enumBinaryMax); + return true; + } + + // If we do not start with the default, then use that + ulong previousValue; + if ((previousValue = values[0].GetBinaryValue()) != 0UL) + { + value = default; + return true; + } + + foreach (var definedValue in values.Skip(1)) + { + // If there is a gap between the current and previous item + var currentValue = definedValue.GetBinaryValue(); + if (currentValue > previousValue + 1) + { + previousValue++; + value = Unsafe.As(ref previousValue); + return true; + } + previousValue = currentValue; + } + + value = default; + return false; + } + + /// + /// Returns the numeric value of the given . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Int128 GetNumericValue(this T enumValue) + where T : unmanaged, Enum + { + // Optimized by JIT, as Type.GetTypeCode(T) is treated as a constant + return Type.GetTypeCode(typeof(T)) switch + { + TypeCode.Byte => (Int128)Unsafe.As(ref enumValue), + TypeCode.SByte => (Int128)Unsafe.As(ref enumValue), + TypeCode.Int16 => (Int128)Unsafe.As(ref enumValue), + TypeCode.UInt16 => (Int128)Unsafe.As(ref enumValue), + TypeCode.Int32 => (Int128)Unsafe.As(ref enumValue), + TypeCode.UInt32 => (Int128)Unsafe.As(ref enumValue), + TypeCode.Int64 => (Int128)Unsafe.As(ref enumValue), + TypeCode.UInt64 => (Int128)Unsafe.As(ref enumValue), + _ => default, + }; + } + + /// + /// + /// Returns the binary value of the given , contained in a . + /// + /// + /// The original value's bytes can be retrieved by doing a cast or to the original enum or underlying type. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong GetBinaryValue(this T enumValue) + where T : unmanaged, Enum + { + var result = 0UL; + + // Since the actual value may be smaller than ulong's 8 bytes, we must align to the least significant byte + // This way, casting the ulong back to the original type gets back the exact original bytes + // On little-endian, that means aligning to the left of the bytes + // On big-endian, that means aligning to the right of the bytes + if (BitConverter.IsLittleEndian) + Unsafe.WriteUnaligned(ref Unsafe.As(ref result), enumValue); + else + Unsafe.WriteUnaligned(ref Unsafe.Add(ref Unsafe.As(ref result), sizeof(ulong) - Unsafe.SizeOf()), enumValue); + + return result; + } +} diff --git a/README.md b/README.md index 0242ccb..832b6ba 100644 --- a/README.md +++ b/README.md @@ -128,95 +128,78 @@ In fact, this can even be advisable. It reduces heap allocations (and thus garba Structs always have a default constructor, and they can also be created via the `default` keyword. To prevent the creation of unvalidated instances, the default constructor for struct wrapper value objects is marked as obsolete, and an included analyzer warns against the use of the `default` keyword for such types. -Structs are usually the way to go. If shenanigans are expected, such as the use of a generic method to produce unvalidated values, then classes can be used to enforce the constructor validation more thoroughly. +Structs are usually the way to go. However, if shenanigans are expected, such as the use of a generic method to produce unvalidated values, then classes can be used to enforce the constructor validation more thoroughly. #### Enums -Special wrapper value objects for enums are provided out-of-the-box. They help avoid the following common boilerplate code: - -- Checking `Enum.IsDefined(value)` when it is injected into a constructor or method. -- Manually mapping to string or the enum's underlying integer type with Entity Framework. -- Configuring how the enum should be JSON-serialized. - -Instead, we can do the following: +Enums can be considered a special kind of wrapper value object. +In fact, one could opt to create a dedicated wrapper value object for each enum used in a domain model. +Fortunately, there is a less cumbersome option. ```cs -public class MyEntity +[ValueObject] +public partial record class Address { - // Maps and serializes to string automatically - public DefinedEnum Kind { get; private set; } - - // Maps and serializes to int automatically - public DefinedEnum StatusCode { get; private set; } - - public MyEntity( - // No need to repeat string/int here - DefinedEnum kind, - DefinedEnum statusCode) + public ProperName StreetAndNumber { get; private init; } + public ProperName City { get; private init; } + public ZipCode ZipCode { get; private init; } + public AddressKind Kind { get; private init; } // Enum: Person, Company + + public Address( + ProperName streetAndNumber, + ProperName city, + ZipCode zipCode, + Kind kind) { - // No need to check if the values are defined - this.Kind = kind; - this.StatusCode = statusCode; + this.StreetAndNumber = streetAndNumber; + this.City = city; + this.ZipCode = zipCode; + + this.Kind = kind; // Compiler warning - possibly undefined enum assigned to domain object member + this.Kind = kind.AsDefined(); // OK - throws if value is undefined } } ``` -`DefinedEnum` can serve as a property or field, which warrants specifying the underlying representation. -`DefinedEnum` is a simplification intended for passing values around. An analyzer prevents the latter from being stored in a field or property. +An included analyzer warns if an unvalidated enum value is assigned to a member of a domain object. +Defined constant values are exempt, e.g. `this.Kind = AddressKind.Person`. -The validation is performed whenever a `DefinedEnum` or `DefinedEnum` is constructed from a primitive. -By default, `ArgumentException` is thrown for an input other than a defined value for the enum, and `ArgumentNullException` for a null input. -However, you can customize the exceptions globally: +The exception can be fully customized: ```cs public class Program { [ModuleInitializer] - internal static void InitializeModule() + internal static void Initialize() { - DefinedEnum.ExceptionFactoryForNullInput = (Type enumType) => - throw new NullValidationException($"{enumType.Name} expects a non-null value."); - DefinedEnum.ExceptionFactoryForUndefinedInput = (Type enumType, Int128 value) => - throw new ValidationException($"Only recognized {enumType.Name} values are permitted."); + DefinedEnum.ExceptionFactoryForUndefinedInput = (Type type, Int128 value, string? state) => + throw new ValidationException(HttpStatusCode.BadRequest, errorCode: state ?? "OptionInvalid", message: $"Only recognized {type.Name} values are permitted."); } } ``` -Type inference and implicit conversions make it easy to produce values: +The following extension methods are available: ```cs -public DefinedEnum Kind { get; private set; } - -public void AdjustKind(DefinedEnum kind) -{ - this.Kind = kind; -} - -public void DemonstrateUsage(Kind someInputThatMightBeUndefined) -{ - // From a constant that represents a defined value: - this.Kind = Kind.Regular; // Valid - this.AdjustKind(Kind.Regular); // Valid - - // Otherwise: - this.Kind = someInputThatMightBeUndefined; // Compiler error - this.Kind = (Kind)(-1); // Compiler error - this.AdjustKind((Kind)(-1)); // Compiler error - this.Kind = DefinedEnum.Create(Kind.Regular); // Valid - this.AdjustKind(DefinedEnum.Create(Kind.Regular)); // Valid -} +this.Kind = kind.AsDefined(); // Throws if value is undefined +this.Kind = kind.AsDefinedFlags(); // Throws if value contains a bit not used in any defined values (for flags) +this.Kind = kind.AsUnvalidated(); // Merely circumvents the warning ``` -When writing a mapper to map from a DTO to a domain object, enums can be converted like this: +Note how, if all constructor parameters of the above `Address` type are [_struct_ wrapper value objects](#structs), it is almost impossible to pass invalid data. +The quick enum validation becomes the only check required. + +When writing a mapper from DTO to domain object, enums can be converted like this: ```cs -public static DefinedEnum ToDomain(KindDto dto) +public static AddressKind ToDomain(AddressKindDto dto) { - return new DefinedEnum(dto switch + return dto switch { - KindDto.A => Kind.A, - KindDto.B => Kind.B, - _ => DefinedEnum.ThrowUndefinedInput(), // Or: DefinedEnum.UndefinedValue!.Value, to have the constructor throw the same thing + AddressKindDto.Person => AddressKind.Person, + AddressKindDto.Company => AddressKind.Company, + _ => DefinedEnum.ThrowUndefinedInput(dto, errorState: "AddressKindInvalid"), + // Or: DefinedEnum.UndefinedValues.UndefinedValue, to rely on AsDefined() throwing later on in the constructor }); } ``` From 402bead38b7a2eee61a24dcfbd87a403c14513da Mon Sep 17 00:00:00 2001 From: Timo van Zijll Langhout <655426+Timovzl@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:45:15 +0200 Subject: [PATCH 23/23] Entity == operator, entity ID generation type param moved from base to attribute, and attribute inheritance. --- ...tyBaseClassWithIdTypeGenerationAnalyzer.cs | 64 ++++++ ...lassWithIdTypeGenerationCodeFixProvider.cs | 133 +++++++++++ .../MissingStringComparisonCodeFixProvider.cs | 5 +- ...nvalidatedEnumMemberAssignmentCodeFixer.cs | 6 +- .../{Description.cs => Currency.cs} | 55 ++--- DomainModeling.Example/Payment.cs | 21 +- DomainModeling.Example/PaymentDummyBuilder.cs | 2 +- DomainModeling.Example/Program.cs | 36 ++- .../DomainEventGenerator.cs | 4 +- .../DummyBuilderGenerator.cs | 10 +- DomainModeling.Generator/EntityGenerator.cs | 6 +- DomainModeling.Generator/IdentityGenerator.cs | 152 ++++++++----- .../TypeDeclarationSyntaxExtensions.cs | 30 ++- .../TypeSymbolExtensions.cs | 215 ++++++++++++++++-- .../TypeSyntaxExtensions.cs | 75 ++---- .../ValueObjectGenerator.cs | 6 +- .../WrapperValueObjectGenerator.cs | 22 +- .../Comparisons/EnumerableComparerTests.cs | 3 +- DomainModeling.Tests/CustomAttributes.cs | 45 ++++ DomainModeling.Tests/DummyBuilderTests.cs | 4 +- .../Entity.SourceGeneratedIdentityTests.cs | 12 +- DomainModeling.Tests/Entities/EntityTests.cs | 80 ++++++- ...ityFrameworkConfigurationGeneratorTests.cs | 12 +- .../FileScopedNamespaceTests.cs | 3 +- DomainModeling.Tests/ValueObjectTests.cs | 3 +- .../Attributes/DomainEventAttribute.cs | 3 + .../Attributes/DummyBuilderAttribute.cs | 3 + DomainModeling/Attributes/EntityAttribute.cs | 28 ++- .../IdentityValueObjectAttribute.cs | 5 +- .../Attributes/ValueObjectAttribute.cs | 3 + .../Attributes/WrapperValueObjectAttribute.cs | 7 +- DomainModeling/DomainModeling.csproj | 12 +- DomainModeling/Entity.cs | 25 +- README.md | 148 +++++++----- 34 files changed, 923 insertions(+), 315 deletions(-) create mode 100644 DomainModeling.Analyzer/Analyzers/EntityBaseClassWithIdTypeGenerationAnalyzer.cs create mode 100644 DomainModeling.CodeFixProviders/EntityBaseClassWithIdTypeGenerationCodeFixProvider.cs rename DomainModeling.Example/{Description.cs => Currency.cs} (55%) create mode 100644 DomainModeling.Tests/CustomAttributes.cs diff --git a/DomainModeling.Analyzer/Analyzers/EntityBaseClassWithIdTypeGenerationAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/EntityBaseClassWithIdTypeGenerationAnalyzer.cs new file mode 100644 index 0000000..5f71bf4 --- /dev/null +++ b/DomainModeling.Analyzer/Analyzers/EntityBaseClassWithIdTypeGenerationAnalyzer.cs @@ -0,0 +1,64 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Architect.DomainModeling.Analyzer.Analyzers; + +/// +/// Encourages migrating from Entity<TId, TPrimitive> to EntityAttribute<TId, TIdUnderlying>. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class EntityBaseClassWithIdTypeGenerationAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "EntityBaseClassWithIdTypeGeneration"; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")] + private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor( + id: "EntityBaseClassWithIdTypeGeneration", + title: "Used entity base class instead of attribute to initiate ID type source generation", + messageFormat: "Entity is deprecated in favor of the [Entity] attribute. Use the extended attribute and remove TIdPrimitive from the base class.", + category: "Design", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptor]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeClassDeclaration, SyntaxKind.ClassDeclaration); + } + + private static void AnalyzeClassDeclaration(SyntaxNodeAnalysisContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + + // Get the first base type (the actual base class rather than interfaces) + if (classDeclaration.BaseList is not { Types: { Count: > 0 } baseTypes } || baseTypes[0] is not { } baseType) + return; + + var typeInfo = context.SemanticModel.GetTypeInfo(baseType.Type, context.CancellationToken); + if (typeInfo.Type is not INamedTypeSymbol baseTypeSymbol) + return; + + while (baseTypeSymbol is not null) + { + if (baseTypeSymbol is { Arity: 2, Name: "Entity", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } } }) + break; + + baseTypeSymbol = baseTypeSymbol.BaseType!; + } + + // If Entity + if (baseTypeSymbol is null) + return; + + var diagnostic = Diagnostic.Create(DiagnosticDescriptor, baseType.GetLocation()); + context.ReportDiagnostic(diagnostic); + } +} diff --git a/DomainModeling.CodeFixProviders/EntityBaseClassWithIdTypeGenerationCodeFixProvider.cs b/DomainModeling.CodeFixProviders/EntityBaseClassWithIdTypeGenerationCodeFixProvider.cs new file mode 100644 index 0000000..80fdfdc --- /dev/null +++ b/DomainModeling.CodeFixProviders/EntityBaseClassWithIdTypeGenerationCodeFixProvider.cs @@ -0,0 +1,133 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Architect.DomainModeling.CodeFixProviders; + +/// +/// Provides a code fix for migrating from Entity<TId, TPrimitive> to EntityAttribute<TId, TIdUnderlying>. +/// +[Shared] +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(EntityBaseClassWithIdTypeGenerationCodeFixProvider))] +public sealed class EntityBaseClassWithIdTypeGenerationCodeFixProvider : CodeFixProvider +{ + private static readonly ImmutableArray FixableDiagnosticIdConstant = ["EntityBaseClassWithIdTypeGeneration"]; + + public sealed override ImmutableArray FixableDiagnosticIds => FixableDiagnosticIdConstant; + + public sealed override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var diagnostic = context.Diagnostics.First(diagnostic => diagnostic.Id == FixableDiagnosticIdConstant[0]); + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + if (root.FindNode(diagnostic.Location.SourceSpan) is not BaseTypeSyntax baseTypeSyntax) + return; + + var tds = baseTypeSyntax.Ancestors().OfType().FirstOrDefault(); + if (tds is null) + return; + + // Do not offer the fix for abstract types + if (tds.Modifiers.Any(SyntaxKind.AbstractKeyword)) + return; + + // Do not offer the fix if the inheritance is indirect + if (baseTypeSyntax.Type is not GenericNameSyntax { Arity: 2, TypeArgumentList.Arguments.Count: 2, } entityBaseTypeSyntax) + return; + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel.GetTypeInfo(baseTypeSyntax.Type, context.CancellationToken).Type is not + INamedTypeSymbol { Arity: 2, Name: "Entity", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } } } entityBaseType) + return; + + var action = CodeAction.Create( + title: "Move type parameter for ID's underlying type into EntityAttribute", + createChangedDocument: ct => ConvertToAttributeAsync(context.Document, root, tds, entityBaseTypeSyntax), + equivalenceKey: "MoveIdTypeGenerationFromBaseToAttribute"); + context.RegisterCodeFix(action, diagnostic); + } + + private static Task ConvertToAttributeAsync( + Document document, + SyntaxNode root, + TypeDeclarationSyntax tds, + GenericNameSyntax baseTypeSyntax) + { + var idTypeToGenerate = baseTypeSyntax.TypeArgumentList.Arguments[0]; + var idUnderlyingType = baseTypeSyntax.TypeArgumentList.Arguments[1]; + + // Create Entity attribute + var attributeArguments = SyntaxFactory.SeparatedList([idTypeToGenerate, idUnderlyingType,]); + var entityAttribute = + SyntaxFactory.Attribute( + SyntaxFactory.GenericName(SyntaxFactory.Identifier("Entity")) + .WithTypeArgumentList(SyntaxFactory.TypeArgumentList(attributeArguments))); + + // Check if Entity attribute already exists + var existingEntityAttribute = tds.AttributeLists + .SelectMany(list => list.Attributes) + .FirstOrDefault(attribute => attribute.Name is IdentifierNameSyntax { Identifier.Text: "Entity" or "EntityAttribute" } or QualifiedNameSyntax { Right.Identifier.Text: "Entity" or "EntityAttribute" }); + + // Replace or add the Entity attribute + TypeDeclarationSyntax newTds; + if (existingEntityAttribute is { Parent: AttributeListSyntax oldAttributeList }) + { + var newAttributes = oldAttributeList.Attributes.Replace(existingEntityAttribute, entityAttribute); + var newAttributeList = oldAttributeList + .WithAttributes(newAttributes) + .WithLeadingTrivia(oldAttributeList.GetLeadingTrivia()) + .WithTrailingTrivia(oldAttributeList.GetTrailingTrivia()); + newTds = tds.ReplaceNode(oldAttributeList, newAttributeList); + } + else + { + // This requires some gymnastics to keep the trivia intact + + // No existing attributes - move the type's leading trivia onto the new attribute + if (tds.AttributeLists.Count == 0) + { + var attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(entityAttribute)) + .WithLeadingTrivia(tds.GetLeadingTrivia()); + var newAttributeLists = tds.AttributeLists.Add(attributeList); + newTds = tds + .WithoutLeadingTrivia() + .WithAttributeLists(newAttributeLists); + } + // Existing attributes - carefully preserve keep the leading trivia on the first attribute + else + { + var attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(entityAttribute)); + var newAttributeLists = tds.AttributeLists + .Replace(tds.AttributeLists[0], tds.AttributeLists[0].WithLeadingTrivia(tds.AttributeLists[0].GetLeadingTrivia())) + .Add(attributeList); + newTds = tds.WithAttributeLists(newAttributeLists); + } + } + + // Replace the base type + if (newTds.BaseList is { Types: { Count: > 0 } baseTypes } && baseTypes[0].Type is GenericNameSyntax { Arity: 2, TypeArgumentList.Arguments: { Count: 2 } typeArgs, } genericBaseType) + { + var newTypeArgs = SyntaxFactory.TypeArgumentList( + SyntaxFactory.SingletonSeparatedList(typeArgs[0])); + var newGenericBaseType = genericBaseType.WithTypeArgumentList(newTypeArgs); + var newBaseType = baseTypes[0] + .WithType(newGenericBaseType) + .WithLeadingTrivia(baseTypes[0].GetLeadingTrivia()) + .WithTrailingTrivia(baseTypes[0].GetTrailingTrivia()); + newTds = newTds.ReplaceNode(baseTypes[0], newBaseType); + } + + var newRoot = root.ReplaceNode(tds, newTds); + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } +} diff --git a/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs b/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs index e4d4020..525d160 100644 --- a/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs +++ b/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs @@ -8,6 +8,9 @@ namespace Architect.DomainModeling.CodeFixProviders; +/// +/// Provides code fixes to add a missing StringComparison property to [Wrapper]ValueObjects with string members. +/// [Shared] [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MissingStringComparisonCodeFixProvider))] public sealed class MissingStringComparisonCodeFixProvider : CodeFixProvider @@ -25,7 +28,6 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) { var diagnostic = context.Diagnostics.First(diagnostic => diagnostic.Id == FixableDiagnosticIdConstant[0] || diagnostic.Id == FixableDiagnosticIdConstant[1]); var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); - if (root is null) return; @@ -33,7 +35,6 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) var tds = token.Parent?.AncestorsAndSelf() .OfType() .FirstOrDefault(); - if (tds is null) return; diff --git a/DomainModeling.CodeFixProviders/UnvalidatedEnumMemberAssignmentCodeFixer.cs b/DomainModeling.CodeFixProviders/UnvalidatedEnumMemberAssignmentCodeFixer.cs index 04e03bf..3c7a179 100644 --- a/DomainModeling.CodeFixProviders/UnvalidatedEnumMemberAssignmentCodeFixer.cs +++ b/DomainModeling.CodeFixProviders/UnvalidatedEnumMemberAssignmentCodeFixer.cs @@ -9,6 +9,9 @@ namespace Architect.DomainModeling.CodeFixProviders; +/// +/// Provides code fixes for unvalid assignments of enum values to domain object members. +/// [Shared] [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UnvalidatedEnumMemberAssignmentCodeFixer))] public sealed class UnvalidatedEnumMemberAssignmentCodeFixer : CodeFixProvider @@ -26,7 +29,8 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) { var diagnostic = context.Diagnostics.First(diagnostic => diagnostic.Id == FixableDiagnosticIdConstant[0]); var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); - if (root is null) return; + if (root is null) + return; var node = root.FindNode(diagnostic.Location.SourceSpan); if (node is not ExpressionSyntax unvalidatedValue) diff --git a/DomainModeling.Example/Description.cs b/DomainModeling.Example/Currency.cs similarity index 55% rename from DomainModeling.Example/Description.cs rename to DomainModeling.Example/Currency.cs index 02cdf2a..5a69c10 100644 --- a/DomainModeling.Example/Description.cs +++ b/DomainModeling.Example/Currency.cs @@ -1,26 +1,29 @@ -using Architect.DomainModeling.Comparisons; - -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 -[WrapperValueObject] -public partial record struct Description //: IComparable -{ - // For string wrappers, we must define how they are compared - private 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; private init; } - - // An explicitly defined constructor allows us to enforce the domain rules and invariants - public Description(string value) - { - this.Value = value ?? throw new ArgumentNullException(nameof(value)); - - if (this.Value.Length > 255) throw new ArgumentException("Too long."); - - if (ValueObjectStringValidator.ContainsNonWordCharacters(this.Value)) throw new ArgumentException("Nonsense."); - } -} +using Architect.DomainModeling.Comparisons; + +namespace Architect.DomainModeling.Example; + +// Use "Go To Definition" on the type to view the source-generated partial +// Outcomment the IComparable interface to see how the generated code changes +[WrapperValueObject] +public partial record struct Currency : IComparable +{ + // For string wrappers, we must define how they are compared + private 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; private init; } + + // An explicitly defined constructor allows us to enforce the domain rules and invariants + public Currency(string value) + { + // Note: We could even choose to do ToUpperInvariant() on the input value, for a more consistent internal representation + this.Value = value ?? throw new ArgumentNullException(nameof(value)); + + if (this.Value.Length != 3) + throw new ArgumentException($"A {nameof(Currency)} must be exactly 3 chars long."); + + if (ValueObjectStringValidator.ContainsNonAsciiOrNonPrintableOrWhitespaceCharacters(this.Value)) + throw new ArgumentException($"A {nameof(Currency)} must consist of simple characters."); + } +} diff --git a/DomainModeling.Example/Payment.cs b/DomainModeling.Example/Payment.cs index 368da27..4f95c97 100644 --- a/DomainModeling.Example/Payment.cs +++ b/DomainModeling.Example/Payment.cs @@ -1,20 +1,23 @@ namespace Architect.DomainModeling.Example; +// An Entity identified by a PaymentId, the latter being a source-generated struct wrapping a string // Use "Go To Definition" on the PaymentId type to view its source-generated implementation -public class Payment : Entity // Entity: An Entity identified by a PaymentId, which is a source-generated struct wrapping a string +[Entity] +public sealed class Payment : Entity // Base class is optional, but offers ID-based equality and a decent ToString() override { - // A default ToString() property based on the type and the Id value is provided by the base class - // Hash code and equality implementations based on the Id value are provided by the base class + // Property Id is declared by base class - // The Id property is provided by the base class - - public string Currency { get; } // Note that Currency deserves its own value object in practice + public Currency Currency { get; } public decimal Amount { get; } - public Payment(string currency, decimal amount) - : base(new PaymentId(Guid.NewGuid().ToString("N"))) // ID generated on construction (see also: https://github.com/TheArchitectDev/Architect.Identities#distributed-ids) + public Payment( + Currency currency, + decimal amount) + : base(new PaymentId(Guid.CreateVersion7().ToString("N"))) // ID generated on construction (see also: https://github.com/TheArchitectDev/Architect.Identities#distributed-ids) { - this.Currency = currency ?? throw new ArgumentNullException(nameof(currency)); + // Note how, thanks to the chosen types, it is hard to pass an invalid value + // (The use of the "default" keyword for struct WrapperValueObjects is prevented by an analyzer) + this.Currency = currency; this.Amount = amount; } } diff --git a/DomainModeling.Example/PaymentDummyBuilder.cs b/DomainModeling.Example/PaymentDummyBuilder.cs index 532ee3f..687c906 100644 --- a/DomainModeling.Example/PaymentDummyBuilder.cs +++ b/DomainModeling.Example/PaymentDummyBuilder.cs @@ -6,7 +6,7 @@ public sealed partial class PaymentDummyBuilder { // The source-generated partial defines a default value for each property, along with a fluent method to change it - private string Currency { get; set; } = "EUR"; // Since the source generator cannot guess a decent default currency, we specify it manually + private Currency Currency { get; set; } = new Currency("EUR"); // Since the source generator cannot guess a decent default currency, we specify it manually // The source-generated partial defines a Build() method that invokes the most visible, simplest parameterized constructor } diff --git a/DomainModeling.Example/Program.cs b/DomainModeling.Example/Program.cs index b4e29e2..0757b66 100644 --- a/DomainModeling.Example/Program.cs +++ b/DomainModeling.Example/Program.cs @@ -1,4 +1,3 @@ -using System.Reflection; using Newtonsoft.Json; namespace Architect.DomainModeling.Example; @@ -23,21 +22,21 @@ public static void Main() { Console.WriteLine("Demonstrating WrapperValueObject:"); - var constructedDescription = new Description("Constructed"); - var castDescription = (Description)"Cast"; + var constructedCurrency = new Currency("EUR"); + var castCurrency = (Currency)"USD"; - Console.WriteLine($"Constructed from string: {constructedDescription}"); - Console.WriteLine($"Cast from string: {castDescription}"); - Console.WriteLine($"Description object cast to string: {(string)constructedDescription}"); + Console.WriteLine($"Constructed from string: {constructedCurrency}"); + Console.WriteLine($"Cast from string: {castCurrency}"); + Console.WriteLine($"Currency object cast to string: {(string)constructedCurrency}"); - var upper = new Description("CASING"); - var lower = new Description("casing"); + var upper = new Currency("EUR"); + var lower = new Currency("eur"); - Console.WriteLine($"{constructedDescription == castDescription}: {constructedDescription} == {castDescription} (different values)"); + Console.WriteLine($"{constructedCurrency == castCurrency}: {constructedCurrency} == {castCurrency} (different values)"); Console.WriteLine($"{upper == lower}: {upper} == {lower} (different only in casing, with ignore-case value object)"); // ValueObjects have structural equality, and this one ignores casing - var serialized = JsonConvert.SerializeObject(new Description("PrettySerializable")); - var deserialized = JsonConvert.DeserializeObject(serialized); + var serialized = JsonConvert.SerializeObject(new Currency("USD")); + var deserialized = JsonConvert.DeserializeObject(serialized); Console.WriteLine($"JSON-serialized: {serialized}"); // Generated serializers for System.Text.Json and Newtonsoft provide serialization as if there was no wrapper object Console.WriteLine($"JSON-deserialized: {deserialized}"); @@ -49,17 +48,16 @@ public static void Main() { Console.WriteLine("Demonstrating Entity:"); - var payment = new Payment("EUR", 1.00m); - var similarPayment = new Payment("EUR", 1.00m); + var payment = new Payment((Currency)"EUR", 1.00m); + var similarPayment = new Payment((Currency)"EUR", 1.00m); Console.WriteLine($"Default ToString() implementation: {payment}"); Console.WriteLine($"{payment.Equals(payment)}: {payment}.Equals({payment}) (same obj)"); - Console.WriteLine($"{payment.Equals(similarPayment)}: {payment}.Equals({similarPayment}) (other obj)"); // Entities have ID-based equality + Console.WriteLine($"{payment.Equals(similarPayment)}: {payment}.Equals({similarPayment}) (other obj)"); // Different entities, even though they look similar - // Demonstrate two different instances with the same ID, to simulate the entity being loaded from a database twice - typeof(Entity).GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(similarPayment, payment.Id); - - Console.WriteLine($"{payment.Equals(similarPayment)}: {payment}.Equals({similarPayment}) (same ID)"); // Entities have ID-based equality + // Entities have reference equality, or even ID-based equality if the Entity base classes are used + // This library aims to avoid forced base classes, and reference equality tends to suffice when entities are used with Entity Framework + // However, the base classes can be used to upgrade to ID-based equality, which allows even to separately loaded instances to be considered equal Console.WriteLine(); } @@ -70,7 +68,7 @@ public static void Main() // The builder pattern prevents tight coupling between test methods and constructor signatures, permitting constructor changes without breaking dozens of tests var defaultPayment = new PaymentDummyBuilder().Build(); - var usdPayment = new PaymentDummyBuilder().WithCurrency("USD").Build(); + var usdPayment = new PaymentDummyBuilder().WithCurrency((Currency)"USD").Build(); Console.WriteLine($"Default Payment from builder: {defaultPayment}, {defaultPayment.Currency}, {defaultPayment.Amount}"); Console.WriteLine($"Customized Payment from builder: {usdPayment}, {usdPayment.Currency}, {usdPayment.Amount}"); diff --git a/DomainModeling.Generator/DomainEventGenerator.cs b/DomainModeling.Generator/DomainEventGenerator.cs index c1b8cc2..a6c209e 100644 --- a/DomainModeling.Generator/DomainEventGenerator.cs +++ b/DomainModeling.Generator/DomainEventGenerator.cs @@ -30,7 +30,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella if (node is TypeDeclarationSyntax tds && tds is ClassDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "class" }) { // With relevant attribute - if (tds.HasAttributeWithPrefix("DomainEvent")) + if (tds.HasAttributeWithInfix("Event")) return true; } @@ -49,7 +49,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; // Only with the attribute - if (type.GetAttribute("DomainEventAttribute", "Architect.DomainModeling", arity: 0) is null) + if (type.GetAttribute(attr => attr.IsOrInheritsClass("DomainEventAttribute", "Architect", "DomainModeling", arity: 0, out _)) is null) return null; // Only concrete diff --git a/DomainModeling.Generator/DummyBuilderGenerator.cs b/DomainModeling.Generator/DummyBuilderGenerator.cs index 74d19cc..e1d73f6 100644 --- a/DomainModeling.Generator/DummyBuilderGenerator.cs +++ b/DomainModeling.Generator/DummyBuilderGenerator.cs @@ -25,7 +25,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or ClassDeclarationSyntax or RecordDeclarationSyntax) { // With relevant attribute - if (tds.HasAttributeWithPrefix("DummyBuilder")) + if (tds.HasAttributeWithInfix("Builder")) return true; } @@ -44,10 +44,10 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; // Only with the attribute - if (type.GetAttribute("DummyBuilderAttribute", "Architect.DomainModeling", arity: 1) is not AttributeData { AttributeClass: not null } attribute) + if (type.GetAttribute(attr => attr.IsOrInheritsClass("DummyBuilderAttribute", "Architect", "DomainModeling", arity: 1, out _)) is not { } attribute) return null; - var modelType = attribute.AttributeClass.TypeArguments[0]; + var modelType = attribute.TypeArguments[0]; var result = new Builder() { @@ -121,8 +121,8 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr context.CancellationToken.ThrowIfCancellationRequested(); var type = compilation.GetTypeByMetadataName(builder.TypeFullMetadataName); - var modelType = type?.GetAttribute("DummyBuilderAttribute", "Architect.DomainModeling", arity: 1) is AttributeData { AttributeClass: not null } attribute - ? attribute.AttributeClass.TypeArguments[0] + var modelType = type?.GetAttribute(attr => attr.IsOrInheritsClass("DummyBuilderAttribute", "Architect", "DomainModeling", arity: 1, out _)) is { } attribute + ? attribute.TypeArguments[0] : null; // No source generation, only above analyzers diff --git a/DomainModeling.Generator/EntityGenerator.cs b/DomainModeling.Generator/EntityGenerator.cs index 899bb68..6362284 100644 --- a/DomainModeling.Generator/EntityGenerator.cs +++ b/DomainModeling.Generator/EntityGenerator.cs @@ -30,7 +30,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella if (node is TypeDeclarationSyntax tds && tds is ClassDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "class" }) { // With relevant attribute - if (tds.HasAttributeWithPrefix("Entity")) + if (tds.HasAttributeWithInfix("Entity")) return true; } @@ -49,7 +49,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; // Only with the attribute - if (type.GetAttribute("EntityAttribute", "Architect.DomainModeling", arity: 0) is null) + if (type.GetAttribute(attr => attr.IsOrInheritsClass("EntityAttribute", "Architect", "DomainModeling", out _)) is null) return null; // Only concrete @@ -90,7 +90,7 @@ private static void GenerateSource(SourceProductionContext context, Generatable if (!generatable.IsEntity) { context.ReportDiagnostic("EntityGeneratorMissingInterface", "Missing IEntity interface", - "Type marked as entity lacks IEntity interface.", DiagnosticSeverity.Warning, generatable.TypeLocation); + "Type marked as entity lacks IEntity interface.", DiagnosticSeverity.Error, generatable.TypeLocation); return; } } diff --git a/DomainModeling.Generator/IdentityGenerator.cs b/DomainModeling.Generator/IdentityGenerator.cs index ab06b55..a247e0a 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -25,22 +25,31 @@ internal void InitializeBasicProvider(IncrementalGeneratorInitializationContext FilterSyntaxNode, (context, ct) => context.SemanticModel.GetDeclaredSymbol((TypeDeclarationSyntax)context.Node) switch { - INamedTypeSymbol type when LooksLikeEntity(type) && IsEntity(type, out var entityInterface) && entityInterface.TypeArguments[0].TypeKind == TypeKind.Error && - entityInterface.TypeArguments[1] is ITypeSymbol underlyingType => + // ID generation requested in Entity base class (legacy) + INamedTypeSymbol type when RequestsIdGenerationViaEntityBase(type, out var entityBaseType) && entityBaseType.TypeArguments[0].TypeKind == TypeKind.Error => new ValueWrapperGenerator.BasicGeneratable( isIdentity: true, containingNamespace: type.ContainingNamespace.ToString(), - wrapperType: entityInterface.TypeArguments[0], - underlyingType: underlyingType, + wrapperType: entityBaseType.TypeArguments[0], + underlyingType: entityBaseType.TypeArguments[1], customCoreType: null), - INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attribute.AttributeClass!.TypeArguments[0] is ITypeSymbol underlyingType => - GetFirstProblem((TypeDeclarationSyntax)context.Node, type, underlyingType) is { } + // ID generation requested in EntityAttribute + INamedTypeSymbol type when HasRelevantEntityAttribute(type, out var attributeType) && attributeType.TypeArguments[0].TypeKind == TypeKind.Error => + new ValueWrapperGenerator.BasicGeneratable( + isIdentity: true, + containingNamespace: type.ContainingNamespace.ToString(), + wrapperType: attributeType.TypeArguments[0], + underlyingType: attributeType.TypeArguments[1], + customCoreType: null), + // ID type with IdentityValueObjectAttribute + INamedTypeSymbol type when HasIdentityAttribute(type, out var attributeType) => + GetFirstProblem((TypeDeclarationSyntax)context.Node, type, attributeType.TypeArguments[0]) is { } ? default : new ValueWrapperGenerator.BasicGeneratable( isIdentity: true, containingNamespace: type.ContainingNamespace.ToString(), wrapperType: type, - underlyingType: underlyingType, + underlyingType: attributeType.TypeArguments[0], customCoreType: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[1]), _ => default, }) @@ -68,24 +77,26 @@ internal void Generate( context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForIdentities); } - private static bool LooksLikeEntity(INamedTypeSymbol type) + private static bool HasRelevantEntityAttribute(INamedTypeSymbol type, out INamedTypeSymbol attributeType) { - var result = type.IsOrInheritsClass(baseType => baseType.Name == "Entity", out _); - return result; + attributeType = null!; + if (type.GetAttribute(attr => attr.IsOrInheritsClass("EntityAttribute", "Architect", "DomainModeling", arity: 2, out _)) is { } attribute) + attributeType = attribute; + return attributeType is not null; } - private static bool IsEntity(INamedTypeSymbol type, out INamedTypeSymbol entityInterface) + private static bool HasIdentityAttribute(INamedTypeSymbol type, out INamedTypeSymbol attributeType) { - var result = type.IsOrInheritsClass(baseType => baseType.Arity == 2 && baseType.IsType("Entity", "Architect", "DomainModeling"), out entityInterface); - return result; + attributeType = null!; + if (type.GetAttribute(attr => attr.IsOrInheritsClass("IdentityValueObjectAttribute", "Architect", "DomainModeling", arity: 1, out _)) is { } attribute) + attributeType = attribute; + return attributeType is not null; } - private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeData attribute) + private static bool RequestsIdGenerationViaEntityBase(INamedTypeSymbol type, out INamedTypeSymbol entityBaseType) { - attribute = null!; - if (type.GetAttribute("IdentityValueObjectAttribute", "Architect.DomainModeling", arity: 1) is AttributeData { AttributeClass: not null } attributeOutput) - attribute = attributeOutput; - return attribute != null; + var result = type.IsOrInheritsClass("Entity", "Architect", "DomainModeling", arity: 2, out entityBaseType); + return result; } private static Diagnostic? GetFirstProblem(TypeDeclarationSyntax tds, INamedTypeSymbol type, ITypeSymbol underlyingType) @@ -143,7 +154,7 @@ private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeDat Diagnostic CreateDiagnostic(string id, string title, string description, DiagnosticSeverity severity) { return Diagnostic.Create( - new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true), + new DiagnosticDescriptor(id, title, description, "Design", severity, isEnabledByDefault: true), type.Locations.FirstOrDefault()); } } @@ -154,19 +165,20 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or ClassDeclarationSyntax or RecordDeclarationSyntax) { // With relevant attribute - if (tds.HasAttributeWithPrefix("IdentityValueObject")) + if (tds.HasAttributeWithInfix("Identity")) return true; } - // Non-generic class with any inherited/implemented types - if (node is ClassDeclarationSyntax cds && cds.Arity == 0 && cds.BaseList is not null) + // Class + if (node is ClassDeclarationSyntax cds) { - // Consider any type with SOME 2-param generic "Entity" inheritance/implementation - foreach (var baseType in cds.BaseList.Types) - { - if (baseType.Type.HasArityAndName(2, "Entity")) - return true; - } + // With SOME arity-2 generic "Entity" inheritance + if (cds.BaseList is { Types: { Count: > 0 } baseTypes } && baseTypes[0].Type is NameSyntax { Arity: 2 } nameSyntax && nameSyntax.GetNameOrDefault() == "Entity") + return true; + + // With relevant attribute + if (cds.HasAttributeWithInfix("Entity")) + return true; } return false; @@ -186,18 +198,19 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; ITypeSymbol underlyingType; - var isBasedOnEntity = LooksLikeEntity(type); - // Path A: An Entity subclass that might be an Entity for which TId may have to be generated - if (isBasedOnEntity) + var hasIdentityAttribute = HasIdentityAttribute(type, out var attributeType); + var hasEntityAttribute = !hasIdentityAttribute && HasRelevantEntityAttribute(type, out attributeType); + + // Path A (legacy): An Entity subclass that might be an Entity for which TId may have to be generated + if (attributeType is null) { - // Only an actual Entity - if (!IsEntity(type, out var entityInterface)) + // Only an actual Entity + if (!RequestsIdGenerationViaEntityBase(type, out var entityBaseType)) return null; - var idType = entityInterface.TypeArguments[0]; - underlyingType = entityInterface.TypeArguments[1]; - result.EntityTypeName = type.Name; + var idType = entityBaseType.TypeArguments[0]; + underlyingType = entityBaseType.TypeArguments[1]; // The ID type exists if it is not of TypeKind.Error result.IdTypeExists = idType.TypeKind != TypeKind.Error; @@ -205,9 +218,15 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella if (result.IdTypeExists) { // Entity was needlessly used, with a preexisting TId - result.Problem = Diagnostic.Create(new DiagnosticDescriptor("EntityIdentityTypeAlreadyExists", "Entity identity type already exists", "Architect.DomainModeling", - "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, isEnabledByDefault: true), type.Locations.FirstOrDefault()); + result.Problem = Diagnostic.Create( + new DiagnosticDescriptor( + "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.", + "Design", + DiagnosticSeverity.Warning, + isEnabledByDefault: true), + tds.BaseList?.Types.FirstOrDefault()?.GetLocation() ?? type.Locations.FirstOrDefault()); return result; } @@ -219,14 +238,42 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // 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); } - // Path B: An annotated type for which a partial may need to be generated - else + // Path B: An Entity type that might have EntityAttribute for which TId may have to be generated + else if (hasEntityAttribute) { - // Only with the attribute - if (!HasRequiredAttribute(type, out var attribute)) - return null; + var idType = attributeType.TypeArguments[0]; + underlyingType = attributeType.TypeArguments[1]; - underlyingType = attribute.AttributeClass!.TypeArguments[0]; + // The ID type exists if it is not of TypeKind.Error + result.IdTypeExists = idType.TypeKind != TypeKind.Error; + + if (result.IdTypeExists) + { + // EntityAttribute was needlessly used, with a preexisting TId + result.Problem = Diagnostic.Create( + new DiagnosticDescriptor( + "EntityIdentityTypeAlreadyExists", + "Entity identity type already exists", + "EntityAttribute is intended to generate source for TId, but TId refers to an existing type. To use an existing identity type, simply use the non-generic EntityAttribute.", + "Design", + DiagnosticSeverity.Warning, + isEnabledByDefault: true), + type.Locations.FirstOrDefault()); + return result; + } + + result.IsStruct = true; + result.ContainingNamespace = type.ContainingNamespace.ToString(); + result.IdTypeName = idType.Name; + + // 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); + } + // Path C: An annotated type for which a partial may need to be generated + else + { + underlyingType = attributeType.TypeArguments[0]; result.IdTypeExists = true; result.IsIIdentity = type.IsOrImplementsInterface(interf => interf.IsType("IIdentity", "Architect", "DomainModeling", arity: 1), out _); @@ -451,7 +498,6 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var containingNamespace = generatable.ContainingNamespace; var idTypeName = generatable.IdTypeName; - var entityTypeName = generatable.EntityTypeName; var underlyingTypeIsStruct = generatable.UnderlyingTypeIsStruct; var isRecord = generatable.IsRecord; var isINumber = generatable.UnderlyingTypeIsINumber; @@ -464,7 +510,7 @@ private static void GenerateSource(SourceProductionContext context, (Generatable var accessibility = generatable.Accessibility; var existingComponents = generatable.ExistingComponents; - var hasIdentityValueObjectAttribute = generatable.IdTypeExists; + var idTypeExists = generatable.IdTypeExists; var directParentOfCore = ValueWrapperGenerator.GetDirectParentOfCoreType(valueWrappers, idTypeName, containingNamespace); var coreTypeFullyQualifiedName = directParentOfCore.CoreTypeFullyQualifiedName ?? generatable.UnderlyingTypeFullyQualifiedName; @@ -484,11 +530,6 @@ private static void GenerateSource(SourceProductionContext context, (Generatable ? coreTypeFullyQualifiedName.Split('.').Last() : coreTypeFullyQualifiedName; - var summary = entityTypeName is null ? null : $@" - /// - /// The identity type used for the entity. - /// "; - // Special case for strings, unless they are explicitly annotated as nullable // 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 @@ -521,13 +562,11 @@ private static void GenerateSource(SourceProductionContext context, (Generatable namespace {containingNamespace} {{ - {summary} - {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} - {(hasIdentityValueObjectAttribute ? "" : $"[IdentityValueObject<{underlyingTypeFullyQualifiedName}>]")} + {(idTypeExists ? "" : $"[IdentityValueObject<{underlyingTypeFullyQualifiedName}>]")} [DebuggerDisplay(""{{ToString(){(coreTypeFullyQualifiedName == "string" ? "" : ",nq")}}}"")] - [CompilerGenerated] {accessibility.ToCodeString()} readonly{(entityTypeName is null ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} : + [CompilerGenerated] {accessibility.ToCodeString()} readonly{(idTypeExists ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} : IIdentity<{underlyingTypeFullyQualifiedName}>, IEquatable<{idTypeName}>, IComparable<{idTypeName}>, @@ -815,7 +854,6 @@ private sealed record Generatable { 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 => 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); } diff --git a/DomainModeling.Generator/TypeDeclarationSyntaxExtensions.cs b/DomainModeling.Generator/TypeDeclarationSyntaxExtensions.cs index f9b7b68..9aa7d63 100644 --- a/DomainModeling.Generator/TypeDeclarationSyntaxExtensions.cs +++ b/DomainModeling.Generator/TypeDeclarationSyntaxExtensions.cs @@ -7,15 +7,6 @@ namespace Architect.DomainModeling.Generator; /// internal static class TypeDeclarationSyntaxExtensions { - /// - /// Returns whether the is a nested type. - /// - public static bool IsNested(this TypeDeclarationSyntax typeDeclarationSyntax) - { - var result = typeDeclarationSyntax.Parent is not BaseNamespaceDeclarationSyntax; - return result; - } - /// /// Returns whether the has any attributes. /// @@ -37,8 +28,25 @@ public static bool HasAttributeWithPrefix(this TypeDeclarationSyntax typeDeclara { 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))) + if (attribute.Name.TryGetNameOnly(out var name) && name.StartsWith(namePrefix)) + return true; + + return false; + } + + /// + /// + /// Returns whether the is directly annotated with an attribute whose name contains the given prefix. + /// + /// + /// Prefixes are useful because a developer may type either "[Obsolete]" or "[ObsoleteAttribute]", and infixes are useful for custom subclasses. + /// + /// + public static bool HasAttributeWithInfix(this TypeDeclarationSyntax typeDeclarationSyntax, string nameInfix) + { + foreach (var attributeList in typeDeclarationSyntax.AttributeLists) + foreach (var attribute in attributeList.Attributes) + if (attribute.Name.TryGetNameOnly(out var name) && name.Contains(nameInfix)) return true; return false; diff --git a/DomainModeling.Generator/TypeSymbolExtensions.cs b/DomainModeling.Generator/TypeSymbolExtensions.cs index 3ab95ed..8733e23 100644 --- a/DomainModeling.Generator/TypeSymbolExtensions.cs +++ b/DomainModeling.Generator/TypeSymbolExtensions.cs @@ -116,6 +116,8 @@ public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string n return result; } + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2) { var result = @@ -132,6 +134,8 @@ typeSymbol.ContainingNamespace is return result; } + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2, int arity) { var result = @@ -149,6 +153,9 @@ typeSymbol.ContainingNamespace is return result; } + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2, string namespaceComponent3) { var result = @@ -169,6 +176,9 @@ typeSymbol.ContainingNamespace is return result; } + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2, string namespaceComponent3, int arity) { var result = @@ -274,6 +284,173 @@ public static bool IsReadOnlySpanOfSpecialType(this ITypeSymbol typeSymbol, Spec return result; } + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsOrInheritsClass(this INamedTypeSymbol typeSymbol, string typeName, string namespaceComponent1, out INamedTypeSymbol targetType) + { + System.Diagnostics.Debug.Assert((typeName, namespaceComponent1) != ("Object", "System"), "This method was optimized in such a way that System.Object cannot be recognized."); + + while (typeSymbol is { SpecialType: not SpecialType.System_Object }) + { + if (typeSymbol.Name == typeName && typeSymbol.ContainingNamespace is { ContainingNamespace.IsGlobalNamespace: true } ns1 && ns1.Name == namespaceComponent1) + { + targetType = typeSymbol; + return true; + } + + typeSymbol = typeSymbol.BaseType!; + } + + targetType = null!; + return false; + } + + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsOrInheritsClass(this INamedTypeSymbol typeSymbol, string typeName, string namespaceComponent1, int arity, out INamedTypeSymbol targetType) + { + System.Diagnostics.Debug.Assert((typeName, namespaceComponent1) != ("Object", "System"), "This method was optimized in such a way that System.Object cannot be recognized."); + + while (typeSymbol is { SpecialType: not SpecialType.System_Object }) + { + if (typeSymbol.Name == typeName && typeSymbol.ContainingNamespace is { ContainingNamespace.IsGlobalNamespace: true } ns1 && ns1.Name == namespaceComponent1 && + typeSymbol.Arity == arity) + { + targetType = typeSymbol; + return true; + } + + typeSymbol = typeSymbol.BaseType!; + } + + targetType = null!; + return false; + } + + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + public static bool IsOrInheritsClass(this INamedTypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2, out INamedTypeSymbol targetType) + { + while (typeSymbol is { SpecialType: not SpecialType.System_Object }) + { + if (typeSymbol.Name == typeName && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace.IsGlobalNamespace: true, + } ns1 + } ns2 && + ns1.Name == namespaceComponent1 && + ns2.Name == namespaceComponent2) + { + targetType = typeSymbol; + return true; + } + + typeSymbol = typeSymbol.BaseType!; + } + + targetType = null!; + return false; + } + + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + public static bool IsOrInheritsClass(this INamedTypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2, int arity, out INamedTypeSymbol targetType) + { + while (typeSymbol is { SpecialType: not SpecialType.System_Object }) + { + if (typeSymbol.Name == typeName && + typeSymbol.Arity == arity && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace.IsGlobalNamespace: true, + } ns1 + } ns2 && + ns1.Name == namespaceComponent1 && + ns2.Name == namespaceComponent2) + { + targetType = typeSymbol; + return true; + } + + typeSymbol = typeSymbol.BaseType!; + } + + targetType = null!; + return false; + } + + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + public static bool IsOrInheritsClass(this INamedTypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2, string namespaceComponent3, out INamedTypeSymbol targetType) + { + while (typeSymbol is { SpecialType: not SpecialType.System_Object }) + { + if (typeSymbol.Name == typeName && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace: + { + ContainingNamespace.IsGlobalNamespace: true, + } ns1 + } ns2 + } ns3 && + ns1.Name == namespaceComponent1 && + ns2.Name == namespaceComponent2 && + ns3.Name == namespaceComponent3) + { + targetType = typeSymbol; + return true; + } + + typeSymbol = typeSymbol.BaseType!; + } + + targetType = null!; + return false; + } + + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + /// A single namespace component, e.g. "Architect", but not "Architect.DomainModeling". + public static bool IsOrInheritsClass(this INamedTypeSymbol typeSymbol, string typeName, string namespaceComponent1, string namespaceComponent2, string namespaceComponent3, int arity, out INamedTypeSymbol targetType) + { + while (typeSymbol is { SpecialType: not SpecialType.System_Object }) + { + if (typeSymbol.Name == typeName && + typeSymbol.Arity == arity && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace: + { + ContainingNamespace.IsGlobalNamespace: true, + } ns1 + } ns2 + } ns3 && + ns1.Name == namespaceComponent1 && + ns2.Name == namespaceComponent2 && + ns3.Name == namespaceComponent3) + { + targetType = typeSymbol; + return true; + } + + typeSymbol = typeSymbol.BaseType!; + } + + targetType = null!; + return false; + } + /// /// Returns whether the is or inherits from a certain class, as determined by the given . /// @@ -287,12 +464,8 @@ public static bool IsOrInheritsClass(this ITypeSymbol typeSymbol, Func - /// Returns whether the is annotated with the specified attribute. + /// Returns the class of the first matching attribute that is on the , or null if there is none. /// - public static AttributeData? GetAttribute(this ITypeSymbol typeSymbol) + public static INamedTypeSymbol? GetAttribute(this ITypeSymbol typeSymbol, Func predicate) { - var result = typeSymbol.GetAttribute(attribute => attribute.IsType()); - return result; - } + foreach (var attribute in typeSymbol.GetAttributes()) + if (attribute.AttributeClass is { } result && predicate(result)) + return result; - /// - /// Returns whether the is annotated with the specified attribute. - /// - public static AttributeData? GetAttribute(this ITypeSymbol typeSymbol, string typeName, string containingNamespace, int? arity = null) - { - var result = typeSymbol.GetAttribute(attribute => (arity is null || attribute.Arity == arity) && attribute.IsTypeWithNamespace(typeName, containingNamespace)); - return result; + return null; } /// - /// Returns whether the is annotated with the specified attribute. + /// Returns the data of the first matching attribute that is on the , or null if there is none. /// - public static AttributeData? GetAttribute(this ITypeSymbol typeSymbol, Func predicate) + public static AttributeData? GetAttributeData(this ITypeSymbol typeSymbol, Func predicate) { - var result = typeSymbol.GetAttributes().FirstOrDefault(attribute => attribute.AttributeClass is not null && predicate(attribute.AttributeClass)); + var result = typeSymbol.GetAttributes().FirstOrDefault(attribute => attribute.AttributeClass is { } type && predicate(type)); return result; } @@ -807,11 +974,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.GetAttribute("WrapperValueObjectAttribute", "Architect.DomainModeling", arity: 1) ?? - typeSymbol.GetAttribute("IdentityValueObjectAttribute", "Architect.DomainModeling", arity: 1)) - is AttributeData wrapperAttribute) + if ((typeSymbol.GetAttribute(attr => attr.IsOrInheritsClass("WrapperValueObjectAttribute", "Architect", "DomainModeling", arity: 1, out _)) ?? + typeSymbol.GetAttribute(attr => attr.IsOrInheritsClass("IdentityValueObjectAttribute", "Architect", "DomainModeling", arity: 1, out _))) + is { } wrapperAttribute) { - return $"new {typeSymbol.WithNullableAnnotation(NullableAnnotation.None)}({wrapperAttribute.AttributeClass!.TypeArguments[0].CreateDummyInstantiationExpression(symbolName, customizedTypes, createCustomTypeExpression, seenTypeSymbols)})"; + return $"new {typeSymbol.WithNullableAnnotation(NullableAnnotation.None)}({wrapperAttribute.TypeArguments[0].CreateDummyInstantiationExpression(symbolName, customizedTypes, createCustomTypeExpression, seenTypeSymbols)})"; } if (typeSymbol.SpecialType == SpecialType.System_String) return $@"""{symbolName.ToTitleCase()}"""; diff --git a/DomainModeling.Generator/TypeSyntaxExtensions.cs b/DomainModeling.Generator/TypeSyntaxExtensions.cs index e284b81..0fb13bd 100644 --- a/DomainModeling.Generator/TypeSyntaxExtensions.cs +++ b/DomainModeling.Generator/TypeSyntaxExtensions.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Architect.DomainModeling.Generator; @@ -8,65 +9,39 @@ namespace Architect.DomainModeling.Generator; internal static class TypeSyntaxExtensions { /// - /// Returns whether the given has the given arity (type parameter count) and (unqualified) name. - /// - /// 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. + /// Returns the given 's name, or null if no name can be obtained. /// - /// 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) + public static string? GetNameOrDefault(this TypeSyntax typeSyntax) { - if (typeSyntax is SimpleNameSyntax simpleName) - { - arity = simpleName.Arity; - unqualifiedName = simpleName.Identifier.ValueText; - } - else if (typeSyntax is QualifiedNameSyntax qualifiedName) - { - arity = qualifiedName.Arity; - unqualifiedName = qualifiedName.Right.Identifier.ValueText; - } - else if (typeSyntax is AliasQualifiedNameSyntax aliasQualifiedName) + var result = typeSyntax switch { - arity = aliasQualifiedName.Arity; - unqualifiedName = aliasQualifiedName.Name.Identifier.ValueText; - } - else - { - arity = -1; - unqualifiedName = null!; - return false; - } - - return true; + SimpleNameSyntax simple => simple.Identifier.ValueText, // SimpleNameSyntax, GenericNameSyntax + QualifiedNameSyntax qualified => qualified.Right.Identifier.ValueText, + AliasQualifiedNameSyntax alias => alias.Name.Identifier.ValueText, + _ => null!, + }; + return result; } /// - /// Returns the given 's name, or null if no name can be obtained. + /// + /// Attempts to extract only the name (e.g. "Uri") from any type or name syntax (e.g. "System.Uri"). + /// + /// + /// Excludes namespaces, generic type arguments, etc. + /// /// - public static string? GetNameOrDefault(this TypeSyntax typeSyntax) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetNameOnly(this TypeSyntax input, out string name) { - return typeSyntax switch + name = input switch { - SimpleNameSyntax simpleName => simpleName.Identifier.ValueText, - QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText, - AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText, - _ => null, + SimpleNameSyntax simple => simple.Identifier.ValueText, // SimpleNameSyntax, GenericNameSyntax + QualifiedNameSyntax qualified => qualified.Right.Identifier.ValueText, + AliasQualifiedNameSyntax alias => alias.Name.Identifier.ValueText, + _ => null!, }; + + return name is not null; } } diff --git a/DomainModeling.Generator/ValueObjectGenerator.cs b/DomainModeling.Generator/ValueObjectGenerator.cs index 40e6e04..e831725 100644 --- a/DomainModeling.Generator/ValueObjectGenerator.cs +++ b/DomainModeling.Generator/ValueObjectGenerator.cs @@ -22,7 +22,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or ClassDeclarationSyntax or RecordDeclarationSyntax) { // With relevant attribute - if (tds.HasAttributeWithPrefix("ValueObject")) + if (tds.HasAttributeWithInfix("ValueObject")) return true; } @@ -43,7 +43,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; // Only with the attribute - if (type.GetAttribute("ValueObjectAttribute", "Architect.DomainModeling", arity: 0) is null) + if (type.GetAttribute(attr => attr.IsOrInheritsClass("ValueObjectAttribute", "Architect", "DomainModeling", arity: 0, out _)) is null) return null; result.IsValueObject = type.IsOrImplementsInterface(type => type.IsType("IValueObject", "Architect", "DomainModeling", arity: 0), out _); @@ -122,7 +122,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella existingComponents |= ValueObjectTypeComponents.StringComparison.If(members.Any(member => member is IPropertySymbol { Name: "StringComparison", IsImplicitlyDeclared: false, } prop)); - existingComponents |= ValueObjectTypeComponents.ValueObjectBaseClass.If(type.IsOrInheritsClass(type => type.IsType("ValueObject", "Architect", "DomainModeling", arity: 0), out _)); + existingComponents |= ValueObjectTypeComponents.ValueObjectBaseClass.If(type.IsOrInheritsClass("ValueObject", "Architect", "DomainModeling", arity: 0, out _)); result.ExistingComponents = existingComponents; diff --git a/DomainModeling.Generator/WrapperValueObjectGenerator.cs b/DomainModeling.Generator/WrapperValueObjectGenerator.cs index 6d0a55e..7b85db8 100644 --- a/DomainModeling.Generator/WrapperValueObjectGenerator.cs +++ b/DomainModeling.Generator/WrapperValueObjectGenerator.cs @@ -26,7 +26,7 @@ internal void InitializeBasicProvider(IncrementalGeneratorInitializationContext FilterSyntaxNode, (context, ct) => context.SemanticModel.GetDeclaredSymbol((TypeDeclarationSyntax)context.Node) switch { - INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attribute.AttributeClass!.TypeArguments[0] is ITypeSymbol underlyingType => + INamedTypeSymbol type when HasRequiredAttribute(type, out var attribute) && attribute.TypeArguments[0] is ITypeSymbol underlyingType => GetFirstProblem((TypeDeclarationSyntax)context.Node, type, underlyingType) is { } ? default : new ValueWrapperGenerator.BasicGeneratable( @@ -61,12 +61,12 @@ internal void Generate( context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForWrapperValueObjects); } - private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeData attribute) + private static bool HasRequiredAttribute(INamedTypeSymbol type, out INamedTypeSymbol attributeType) { - attribute = null!; - if (type.GetAttribute("WrapperValueObjectAttribute", "Architect.DomainModeling", arity: 1) is AttributeData { AttributeClass: not null } attributeOutput) - attribute = attributeOutput; - return attribute != null; + attributeType = null!; + if (type.GetAttribute(attr => attr.IsOrInheritsClass("WrapperValueObjectAttribute", "Architect", "DomainModeling", arity: 1, out _)) is { } attribute) + attributeType = attribute; + return attributeType is not null; } private static Diagnostic? GetFirstProblem(TypeDeclarationSyntax tds, INamedTypeSymbol type, ITypeSymbol underlyingType) @@ -118,7 +118,7 @@ private static bool HasRequiredAttribute(INamedTypeSymbol type, out AttributeDat Diagnostic CreateDiagnostic(string id, string title, string description, DiagnosticSeverity severity) { return Diagnostic.Create( - new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true), + new DiagnosticDescriptor(id, title, description, "Design", severity, isEnabledByDefault: true), type.Locations.FirstOrDefault()); } } @@ -129,7 +129,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella if (node is TypeDeclarationSyntax tds && tds is StructDeclarationSyntax or ClassDeclarationSyntax or RecordDeclarationSyntax) { // With relevant attribute - if (tds.HasAttributeWithPrefix("WrapperValueObject")) + if (tds.HasAttributeWithInfix("Wrapper")) return true; } @@ -151,7 +151,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella if (!HasRequiredAttribute(type, out var attribute)) return null; - var underlyingType = attribute.AttributeClass!.TypeArguments[0]; + var underlyingType = attribute.TypeArguments[0]; var result = new Generatable(); result.IsWrapperValueObject = type.IsOrImplementsInterface(type => type.IsType("IWrapperValueObject", "Architect", "DomainModeling", arity: 1), out _); @@ -181,7 +181,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella // It is also implemented if the underlying type is an annotated identity result.IsComparable = type.AllInterfaces.Any(interf => interf.IsSystemType("IComparable", arity: 1) && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default)) && underlyingType.IsComparable(seeThroughNullable: true); - result.IsComparable |= underlyingType.GetAttribute("IdentityValueObjectAttribute", "Architect.DomainModeling", arity: 1) is not null; + result.IsComparable |= underlyingType.GetAttribute(attr => attr.IsOrInheritsClass("IdentityValueObjectAttribute", "Architect", "DomainModeling", arity: 1, out _)) is not null; var members = type.GetMembers(); @@ -362,7 +362,7 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2) && !interf.IsImplicitlyDeclared && interf.TypeArguments[0].Equals(type, SymbolEqualityComparer.Default))); - existingComponents |= WrapperValueObjectTypeComponents.WrapperBaseClass.If(type.IsOrInheritsClass(type => type.IsType("WrapperValueObject", "Architect", "DomainModeling", arity: 1), out _)); + existingComponents |= WrapperValueObjectTypeComponents.WrapperBaseClass.If(type.IsOrInheritsClass("WrapperValueObject", "Architect", "DomainModeling", arity: 1, out _)); result.ExistingComponents = existingComponents; result.ValueMemberLocation = members.FirstOrDefault(member => member.Name == "Value" && member is IFieldSymbol or IPropertySymbol)?.Locations.FirstOrDefault(); diff --git a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs index 9504d48..bff5b0c 100644 --- a/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs +++ b/DomainModeling.Tests/Comparisons/EnumerableComparerTests.cs @@ -392,7 +392,8 @@ public void GetSpanHashCode_BetweenNullableInstances_ShouldReturnExpectedResult( Assert.Equal(expectedResult, leftHashCode == rightHashCode); } - private sealed class StringIdEntity : Entity + [Entity] + private sealed class StringIdEntity : Entity { public StringIdEntity(SomeStringId id) : base(id) diff --git a/DomainModeling.Tests/CustomAttributes.cs b/DomainModeling.Tests/CustomAttributes.cs new file mode 100644 index 0000000..62231f6 --- /dev/null +++ b/DomainModeling.Tests/CustomAttributes.cs @@ -0,0 +1,45 @@ +namespace Architect.DomainModeling.Tests; + +// Test custom subtypes of the attributes allow testing that even subclasses are recognized, provided that the naming is recognizable + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class TestEntityAttribute : EntityAttribute +{ +} + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class TestEntityAttribute< + TId, + TIdUnderlying> : EntityAttribute + where TId : IEquatable?, IComparable? + where TIdUnderlying : IEquatable?, IComparable? +{ +} + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class TestDomainEventAttribute : DomainEventAttribute +{ +} + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class TestValueObjectAttribute : ValueObjectAttribute +{ +} + +[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class TestIdentityAttribute : IdentityValueObjectAttribute + where T : notnull, IEquatable, IComparable +{ +} + +[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class TestWrapperAttribute : WrapperValueObjectAttribute + where TValue : notnull +{ +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class TestBuilderAttribute : DummyBuilderAttribute + where TModel : notnull +{ +} diff --git a/DomainModeling.Tests/DummyBuilderTests.cs b/DomainModeling.Tests/DummyBuilderTests.cs index 0502d3b..19aceca 100644 --- a/DomainModeling.Tests/DummyBuilderTests.cs +++ b/DomainModeling.Tests/DummyBuilderTests.cs @@ -91,8 +91,8 @@ public sealed partial record class TestEntityDummyBuilder public TestEntityDummyBuilder WithCreationDateTime(DateTime value) => this.With(b => b.CreationDateTime = value); } - [Entity] - public sealed class TestEntity : Entity + [Entity] + public sealed class TestEntity : Entity { public DateTime CreationDateTime { get; } public DateOnly CreationDate { get; } diff --git a/DomainModeling.Tests/Entities/Entity.SourceGeneratedIdentityTests.cs b/DomainModeling.Tests/Entities/Entity.SourceGeneratedIdentityTests.cs index 43ff195..b93d9e6 100644 --- a/DomainModeling.Tests/Entities/Entity.SourceGeneratedIdentityTests.cs +++ b/DomainModeling.Tests/Entities/Entity.SourceGeneratedIdentityTests.cs @@ -427,7 +427,8 @@ private static int NormalizeComparisonResult(int result) return 0; } - private sealed class StringBasedEntity : Entity + [Entity] + private sealed class StringBasedEntity : Entity { public StringBasedEntity(StringId id) : base(id) @@ -435,7 +436,8 @@ public StringBasedEntity(StringId id) } } - private sealed class IntBasedEntity : Entity + [Entity] + private sealed class IntBasedEntity : Entity { public IntBasedEntity(IntId id) : base(id) @@ -443,7 +445,8 @@ public IntBasedEntity(IntId id) } } - private sealed class DecimalBasedEntity : Entity + [Entity] + private sealed class DecimalBasedEntity : Entity { public DecimalBasedEntity(DecimalId id) : base(id) @@ -451,7 +454,8 @@ public DecimalBasedEntity(DecimalId id) } } - public sealed class ObjectBasedEntity : Entity + [Entity] + public sealed class ObjectBasedEntity : Entity { public ObjectBasedEntity(ObjectId id) : base(id) diff --git a/DomainModeling.Tests/Entities/EntityTests.cs b/DomainModeling.Tests/Entities/EntityTests.cs index f23a2cf..ad44e6c 100644 --- a/DomainModeling.Tests/Entities/EntityTests.cs +++ b/DomainModeling.Tests/Entities/EntityTests.cs @@ -44,6 +44,44 @@ public void Equals_WithClassId_ShouldEquateAsExpected(int? value, bool expectedR Assert.Equal(expectedResult, one.Equals(two)); } + [Theory] + [InlineData(null, false)] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(-1, true)] + public void EqualityOperator_WithClassId_ShouldEquateAsExpected(int? value, bool expectedResult) + { + var one = new ClassIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, }); + var two = new ClassIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, }); + +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable CS1718 // Comparison made to same variable -- Still need to test the operator + Assert.True(one == one); + Assert.True(two == two); + Assert.Equal(expectedResult, one == two); +#pragma warning restore CS1718 // Comparison made to same variable +#pragma warning restore IDE0079 // Remove unnecessary suppression + } + + [Theory] + [InlineData(null, false)] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(-1, true)] + public void InequalityOperator_WithClassId_ShouldEquateAsExpected(int? value, bool expectedResult) + { + var one = new ClassIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, }); + var two = new ClassIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, }); + +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable CS1718 // Comparison made to same variable -- Still need to test the operator + Assert.False(one != one); + Assert.False(two != two); + Assert.NotEqual(expectedResult, one != two); +#pragma warning restore CS1718 // Comparison made to same variable +#pragma warning restore IDE0079 // Remove unnecessary suppression + } + [Theory] [InlineData(null, true)] [InlineData(0UL, true)] @@ -81,6 +119,42 @@ public void Equals_WithStructId_ShouldEquateAsExpected(ulong? value, bool expect Assert.Equal(expectedResult, one.Equals(two)); } + [Theory] + [InlineData(null, false)] + [InlineData(0UL, false)] + [InlineData(1UL, true)] + public void EqualityOperator_WithStructId_ShouldEquateAsExpected(ulong? value, bool expectedResult) + { + var one = new StructIdEntity(value is null ? default : new UlongId(value.Value)); + var two = new StructIdEntity(value is null ? default : new UlongId(value.Value)); + +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable CS1718 // Comparison made to same variable -- Still need to test the operator + Assert.True(one == one); + Assert.True(two == two); + Assert.Equal(expectedResult, one == two); +#pragma warning restore CS1718 // Comparison made to same variable +#pragma warning restore IDE0079 // Remove unnecessary suppression + } + + [Theory] + [InlineData(null, false)] + [InlineData(0UL, false)] + [InlineData(1UL, true)] + public void InequalityOperator_WithStructId_ShouldEquateAsExpected(ulong? value, bool expectedResult) + { + var one = new StructIdEntity(value is null ? default : new UlongId(value.Value)); + var two = new StructIdEntity(value is null ? default : new UlongId(value.Value)); + +#pragma warning disable IDE0079 // Remove unnecessary suppression -- Suppression below is falsely flagged as unnecessary +#pragma warning disable CS1718 // Comparison made to same variable -- Still need to test the operator + Assert.False(one != one); + Assert.False(two != two); + Assert.NotEqual(expectedResult, one != two); +#pragma warning restore CS1718 // Comparison made to same variable +#pragma warning restore IDE0079 // Remove unnecessary suppression + } + [Theory] [InlineData(null, true)] [InlineData("", false)] @@ -244,7 +318,8 @@ public void Equals_WithAbstractId_ShouldEquateAsExpected(int? value, bool expect Assert.Equal(expectedResult, one.Equals(two)); } - private sealed class StructIdEntity : Entity + [Entity] + private sealed class StructIdEntity : Entity { public StructIdEntity(ulong id) : base(new UlongId(id)) @@ -282,7 +357,8 @@ public OtherStringIdEntity(string id) } } - private sealed class StringWrappingIdEntity : Entity + [Entity] + private sealed class StringWrappingIdEntity : Entity { public StringWrappingIdEntity(StringBasedId id) : base(id) diff --git a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs index 3695dc3..43c6ec9 100644 --- a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs +++ b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs @@ -182,7 +182,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } -[DomainEvent] +[TestDomainEvent] internal sealed class DomainEventForEF : IDomainObject { /// @@ -202,7 +202,7 @@ public DomainEventForEF(DomainEventForEFId id, object ignored) this.Id = id; } } -[IdentityValueObject] +[TestIdentity] public readonly partial record struct DomainEventForEFId; [IdentityValueObject] @@ -211,7 +211,7 @@ public partial record struct EntityForEFId private StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; } -[Entity] +[TestEntity] internal sealed class EntityForEF : Entity { /// @@ -240,7 +240,7 @@ private EntityForEF() #pragma warning restore IDE0079 } -[WrapperValueObject] +[TestWrapper] internal sealed partial class Wrapper1ForEF { private StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; @@ -291,7 +291,7 @@ internal sealed partial class LazyIntWrapper : ICoreValueWrapper.Deserialize(int value) => DomainObjectSerializer.Deserialize>(new Lazy(value)); } -[IdentityValueObject] +[TestIdentity] internal partial struct NumericStringId : ICoreValueWrapper // Custom core value { // Manual interface implementation to support custom core value @@ -301,7 +301,7 @@ internal partial struct NumericStringId : ICoreValueWrapper.Deserialize(int value) => DomainObjectSerializer.Deserialize(value.ToString()); } -[ValueObject] +[TestValueObject] internal sealed partial class ValueObjectForEF { /// diff --git a/DomainModeling.Tests/FileScopedNamespaceTests.cs b/DomainModeling.Tests/FileScopedNamespaceTests.cs index 6ee91f7..bd7508a 100644 --- a/DomainModeling.Tests/FileScopedNamespaceTests.cs +++ b/DomainModeling.Tests/FileScopedNamespaceTests.cs @@ -23,7 +23,8 @@ public partial struct FileScopedId { } -public partial class FileScopedNamespaceEntity : Entity +[Entity] +public partial class FileScopedNamespaceEntity : Entity { public FileScopedNamespaceEntity() : base(default) diff --git a/DomainModeling.Tests/ValueObjectTests.cs b/DomainModeling.Tests/ValueObjectTests.cs index 9c13143..e2303a1 100644 --- a/DomainModeling.Tests/ValueObjectTests.cs +++ b/DomainModeling.Tests/ValueObjectTests.cs @@ -1031,7 +1031,8 @@ public ValueObjectWithGeneratedIdentity(FullyGeneratedId someValue) this.SomeValue = someValue; } - public sealed class Entity : Entity + [Entity] + public sealed class Entity : Entity { public Entity() : base(default) diff --git a/DomainModeling/Attributes/DomainEventAttribute.cs b/DomainModeling/Attributes/DomainEventAttribute.cs index 27dd649..9722b99 100644 --- a/DomainModeling/Attributes/DomainEventAttribute.cs +++ b/DomainModeling/Attributes/DomainEventAttribute.cs @@ -12,6 +12,9 @@ namespace Architect.DomainModeling; /// 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. /// +/// +/// Subclasses of this attribute are also honored, provided that they contain "Event" in their name. +/// /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] public class DomainEventAttribute : Attribute diff --git a/DomainModeling/Attributes/DummyBuilderAttribute.cs b/DomainModeling/Attributes/DummyBuilderAttribute.cs index 0e5ee7b..321e909 100644 --- a/DomainModeling/Attributes/DummyBuilderAttribute.cs +++ b/DomainModeling/Attributes/DummyBuilderAttribute.cs @@ -19,6 +19,9 @@ namespace Architect.DomainModeling; /// 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. /// +/// +/// Subclasses of this attribute are also honored, provided that they contain "Builder" in their name. +/// /// /// The model type produced by the annotated dummy builder. [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] diff --git a/DomainModeling/Attributes/EntityAttribute.cs b/DomainModeling/Attributes/EntityAttribute.cs index 761de67..fbf7e0e 100644 --- a/DomainModeling/Attributes/EntityAttribute.cs +++ b/DomainModeling/Attributes/EntityAttribute.cs @@ -6,14 +6,38 @@ 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. +/// +/// +/// Subclasses of this attribute are also honored, provided that they contain "Entity" in their name. +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class EntityAttribute : Attribute +{ +} + +/// +/// +/// Marks a type as a DDD entity in the domain model, with generated custom ID type , which wraps . /// /// /// 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. +/// If they all need to use a FruitId, then use the non-generic , and manually define FruitId with the . +/// +/// +/// Subclasses of this attribute are also honored, provided that they contain "Entity" in their name. /// /// +/// The custom ID type for this entity. The type is source-generated if a nonexistent type is specified. +/// The underlying type used by the custom ID type. [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public class EntityAttribute : Attribute +public class EntityAttribute< + TId, + TIdUnderlying> : EntityAttribute + where TId : IEquatable?, IComparable? + where TIdUnderlying : IEquatable?, IComparable? { } diff --git a/DomainModeling/Attributes/IdentityValueObjectAttribute.cs b/DomainModeling/Attributes/IdentityValueObjectAttribute.cs index e3db26f..29cbdbb 100644 --- a/DomainModeling/Attributes/IdentityValueObjectAttribute.cs +++ b/DomainModeling/Attributes/IdentityValueObjectAttribute.cs @@ -13,10 +13,13 @@ namespace Architect.DomainModeling; /// 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. /// +/// +/// Subclasses of this attribute are also honored, provided that they contain "Identity" in their name. +/// /// /// The underlying type wrapped by the annotated identity type. [AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public class IdentityValueObjectAttribute : ValueObjectAttribute +public class IdentityValueObjectAttribute : Attribute where T : notnull, IEquatable, IComparable { } diff --git a/DomainModeling/Attributes/ValueObjectAttribute.cs b/DomainModeling/Attributes/ValueObjectAttribute.cs index fd1ba8b..f0a7346 100644 --- a/DomainModeling/Attributes/ValueObjectAttribute.cs +++ b/DomainModeling/Attributes/ValueObjectAttribute.cs @@ -12,6 +12,9 @@ namespace Architect.DomainModeling; /// 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. /// +/// +/// Subclasses of this attribute are also honored, provided that they contain "ValueObject" in their name. +/// /// [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 index 7cc651e..50e0750 100644 --- a/DomainModeling/Attributes/WrapperValueObjectAttribute.cs +++ b/DomainModeling/Attributes/WrapperValueObjectAttribute.cs @@ -13,10 +13,13 @@ namespace Architect.DomainModeling; /// 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. /// +/// +/// Subclasses of this attribute are also honored, provided that they contain "Wrapper" in their name. +/// /// /// The underlying type wrapped by the annotated wrapper value object type. -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] -public class WrapperValueObjectAttribute : ValueObjectAttribute +[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public class WrapperValueObjectAttribute : Attribute where TValue : notnull { } diff --git a/DomainModeling/DomainModeling.csproj b/DomainModeling/DomainModeling.csproj index 3b90eb7..e11c7b4 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -42,6 +42,7 @@ The base class is dead - long live the interface! - Feature: Generated [Wrapper]ValueObjects can now be records or use custom base. - BREAKING: [Wrapper]ValueObject generator replaced base class by interface. StringComparison property may need to drop "override" keyword and become private. - BREAKING: Lack of ValueObject base class requires the string validation methods to be accessed via ValueObjectStringValidator class. +- BREAKING: Entity<TId, TPrimitive> type params moved from base class to attribute. Completed support for nested wrappers: - Feature: Nested Wrappers/Identities can now also convert, wrap/unwrap, and serialize/deserialize directly to/from their core (deepest) underlying type. @@ -62,6 +63,7 @@ Performance: - Enhancement: Improved source generator performance. Misc: +- Semi-breaking: Entity<TId> now has ID-based ==/!=. - Semi-breaking: IFormattable & co for string wrappers have stopped treating null strings as "", which covered up mistakes instead of revealing them. - Semi-breaking: IIdentity now implements IWrapperValueObject. - Feature: Analyzer and extensions for defined enums. @@ -69,14 +71,14 @@ Misc: - Feature: DummyBuilder records clone on each step, for reuse. - Feature: Analyzer warns when '==' or similar operator implicitly casts some IValueObject to something else. Avoids accidentally comparing unrelated types. - Feature: Analyzer warns when '>' or similar operator risks unintended null handling. -- Fix: Fixed bug where source-generated records would ignore hand-written ToString()/Equals()/GetHashCode(). -- Fix: Fixed bug where source-generated Wrappers/Identities would not recognize manual member implementations if they were explicit interface implementations. -- Fix: Fixed bug where DummyBuilder generator struggled with nested types. -- Fix: Fixed bug where "no source generation on nested type" warning would not show. +- Fixed: Attribute inheritence. +- Fixed: Source-generated records would ignore hand-written ToString()/Equals()/GetHashCode(). +- Fixed: Source-generated Wrappers/Identities would not recognize manual member implementations if they were explicit interface implementations. +- Fixed: DummyBuilder generator would struggle with nested types. +- Fixed: "No source generation on nested type" warning would not show. - Enhancement: CompilerGeneratedAttribute throughout. - Enhancement: DebuggerDisplay for Wrappers/Identities. - Enhancement: Analyzer warning clarity. -- Enhancement: Improved correctness of trimming. The Architect The Architect diff --git a/DomainModeling/Entity.cs b/DomainModeling/Entity.cs index 143617a..7b626c3 100644 --- a/DomainModeling/Entity.cs +++ b/DomainModeling/Entity.cs @@ -34,7 +34,7 @@ protected Entity(TId 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 + // This lets us avoid an exact type match, which lets us consider a Fruit equal to a Banana if their IDs match if (other is not Entity) return false; @@ -51,7 +51,8 @@ public override bool Equals(Entity? other) /// 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. /// /// -/// automatically declares an ID property of type , as well as overriding certain behavior to make use of it. +/// automatically declares an ID property of type . +/// It overrides equality and to be based on the ID and entity type, and provides equality operators. /// /// [Serializable] @@ -81,7 +82,7 @@ public abstract class Entity< /// /// The entity's unique identity. /// - public TId Id { get; } + public virtual TId Id { get; } /// The unique identity for the entity. protected Entity(TId id) @@ -89,6 +90,9 @@ protected Entity(TId id) this.Id = id; } + /// + /// Returns an ID-based hash code for the current entity. + /// public override int GetHashCode() { // With a null or default-valued ID, use a reference-based hash code, to match Equals() @@ -97,11 +101,17 @@ public override int GetHashCode() : this.Id.GetHashCode(); } + /// + /// Compares the current entity to the by type and ID. + /// public override bool Equals(object? other) { return other is Entity otherId && this.Equals(otherId); } + /// + /// Compares the current entity to the by type and ID. + /// public virtual bool Equals(Entity? other) { if (other is null) @@ -114,6 +124,15 @@ public virtual bool Equals(Entity? other) return ReferenceEquals(this, other) || (this.Id is not null && !this.Id.Equals(DefaultId) && this.Id.Equals(other.Id) && this.GetType() == other.GetType()); } + + /// + /// Compares the entity to the by type and ID. + /// + public static bool operator ==(Entity left, Entity right) => left?.Equals(right) ?? right is null; + /// + /// Compares the entity to the by type and ID. + /// + public static bool operator !=(Entity left, Entity right) => !(left == right); } /// diff --git a/README.md b/README.md index 832b6ba..846f904 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A complete Domain-Driven Design (DDD) toolset for implementing domain models, including base types and source generators. -- Base types, including: `ValueObject`, `WrapperValueObject`, `Entity`, `IIdentity`, `IApplicationService`, `IDomainService`. -- Source generators, for types including: `ValueObject`, `WrapperValueObject`, `DummyBuilder`, `IIdentity`. +- Base types and interfaces, including: `[I]ValueObject`, `[I]WrapperValueObject`, `[I]Entity`, `IIdentity`, `IApplicationService`, `IDomainService`. +- Source generators, for types including: `IValueObject`, `IWrapperValueObject`, `IIdentity`, `DummyBuilder`. - 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. @@ -12,7 +12,8 @@ A complete Domain-Driven Design (DDD) toolset for implementing domain models, in This package uses source generators (introduced in .NET 5). Source generators write additional C# code as part of the compilation process. -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. +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. ## Domain Object Types @@ -25,8 +26,13 @@ Consider the following type: ```cs public class Color { + [JsonInclude, JsonPropertyName("Red")] public ushort Red { get; private init; } + + [JsonInclude, JsonPropertyName("Green")] public ushort Green { get; private init; } + + [JsonInclude, JsonPropertyName("Blue")] public ushort Blue { get; private init; } public Color(ushort red, ushort green, ushort blue) @@ -38,7 +44,10 @@ public class Color } ``` -This is the non-boilerplate portion of the value object, i.e. everything that we would like to define by hand. However, the type is missing the following: +_As a side note, if a value object is ever serialized to JSON, then the sensible `private init` makes `[JsonInclude]` necessary to include the property, +and `[JsonPropertyName("UnchangingStringConstant")]` provides backward compatibility if the names are ever changed (an easy oversight)._ + +The above is the non-boilerplate portion of the value object, i.e. everything that we would like to define by hand. However, the type is missing the following: - A `ToString()` override. - A `GetHashCode()` override. @@ -91,16 +100,18 @@ public class Description { this.Value = value ?? throw new ArgumentNullException(nameof(value)); - 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."); + 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 (ValueObjectStringValidator.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 `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?`). @@ -140,9 +151,16 @@ Fortunately, there is a less cumbersome option. [ValueObject] public partial record class Address { + [JsonInclude, JsonPropertyName("StreetAndNumber")] public ProperName StreetAndNumber { get; private init; } + + [JsonInclude, JsonPropertyName("City")] public ProperName City { get; private init; } + + [JsonInclude, JsonPropertyName("ZipCode")] public ZipCode ZipCode { get; private init; } + + [JsonInclude, JsonPropertyName("Kind")] public AddressKind Kind { get; private init; } // Enum: Person, Company public Address( @@ -204,83 +222,85 @@ public static AddressKind ToDomain(AddressKindDto dto) } ``` +### Identity + +Identity types are a special case of wrapper value object, with some noteworthy characteristics: + +- An ID tends to lack the need for constructor validation. +- The default constructor is unproblematic, because there is hardly such a thing as an invalid ID value. Although ID 0 or -1 might not _exist_, the same might be true for ID 999999, which would still be valid as a value. +- The possibility of an ID variable containing `null` is often undesirable. Structs avoid this complication. (Where we _want_ nullability, a nullable struct can be used, e.g. `PaymentId?`. +- If the underlying type is `string`, the generator ensures that its `Value` property returns the empty string instead of `null`. This way, even `string`-wrapping identities know only one "empty" value and avoid ever representing `null` as anything special. + +Source-generated identities implement both `IEquatable` and `IComparable` automatically. They are declared as follows: + +```cs +[IdentityValueObject] +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. + ### 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. -For entities themselves, the package offers base types, with no source generation required. However, it is often desirable to have a custom type for an entity's ID. For example, `PaymentId` tends to be a more expressive type than `ulong`. Unfortunately, such custom ID types tend to consist of boilerplate code that gets in the way, is a hassle to write, and is easy to make mistakes in. - +The package offers the `Entity` base class, which offers ID-based equality, with no source generation required. Consider the following type: ```cs [Entity] public class Payment : Entity { - public string Currency { get; } - public decimal Amount { get; } + public Currency Currency { get; private set; } // Struct WrapperValueObject :) + public decimal Amount { get; private set; } - public Payment(string currency, decimal amount) + public Payment( + Currency currency, + decimal amount) : base(new PaymentId()) { - this.Currency = currency ?? throw new ArgumentNullException(nameof(currency)); + this.Currency = currency; this.Amount = amount; } } ``` -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, a lightweight, more expressive wrapper around something like `Guid`. We could have it generated with a oneliner: -Change the type as follows to get a source-generated ID type for the entity: +```cs +[IdentityValueObject] public partial record struct PaymentId; +``` + +We can even avoid that extra type declaration we would otherwise need to put somewhere: ```cs -[Entity] -public class Payment : Entity +[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. -Using this base class to have the ID type generated is equivalent to [manually declaring one](#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`'. +The `[Entity]` attribute triggers source generation of `TId` as an `IIdentity` wrapping that underlying type. Furthermore, the above example entity could be modified to create a new, unique ID on construction: ```cs -public Payment(string currency, decimal amount) - : base(new PaymentId(Guid.NewGuid().ToString("N"))) +public Payment( + Currency currency, + decimal amount) + : base(new PaymentId(Guid.CreateVersion7())) { // Snip } ``` -For a more database-friendly alternative to UUIDs, see [Distributed IDs](https://github.com/TheArchitectDev/Architect.Identities#distributed-ids). - -### Identity - -Identity types are a special case of wrapper value object, with some noteworthy characteristics: - -- An ID tends to lack the need for constructor validation. -- The default constructor is unproblematic, because there is hardly such a thing as an invalid ID value. Although ID 0 or -1 might not _exist_, the same might be true for ID 999999, which would still be valid as a value. -- The possibility of an ID variable containing `null` is often undesirable. Structs avoid this complication. (Where we _want_ nullability, a nullable struct can be used, e.g. `PaymentId?`. -- If the underlying type is `string`, the generator ensures that its `Value` property returns the empty string instead of `null`. This way, even `string`-wrapping identities know only one "empty" value and avoid ever representing `null` as anything special. - -Source-generated identities implement both `IEquatable` and `IComparable` automatically. They are declared as follows: - -```cs -[IdentityValueObject] -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. +For a more developer-friendly _and_ database-friendly alternative to UUIDs, see the [DistributedId](https://github.com/TheArchitectDev/Architect.Identities#distributed-ids) and [DistributedId128](https://github.com/TheArchitectDev/Architect.Identities#distributedid128). ### 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. +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, irrespective of how they are implemented, the `[DomainEvent]` attribute can be used: @@ -289,7 +309,8 @@ To mark domain event types as such, irrespective of how they are implemented, th public class OrderCreatedEvent : // Snip ``` -Besides providing consistency, such a marker attribute can enable miscellaneous concerns. For example, if this package's [Entity Framework conventions](#entity-framework-conventions) are used, domain events can be included. +Besides providing consistency, such a marker attribute can enable miscellaneous concerns. +For example, if this package's [Entity Framework conventions](#entity-framework-conventions) are used, domain events can be included. ### DummyBuilder @@ -383,14 +404,15 @@ public Description(string value) { this.Value = value ?? throw new ArgumentNullException(nameof(value)); - 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."); + 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 (ValueObjectStringValidator.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. Functionally, the object is considered to have lived on. @@ -451,7 +473,7 @@ internal sealed class MyDbContext : DbContext { // Snip - [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "Suppression is necessary.")] + [SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")] [SuppressMessage("Usage", "CA2263:Prefer generic overload when type is known", Justification = "We have no generic info for types received from callbacks.")] protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { @@ -466,6 +488,7 @@ internal sealed class MyDbContext : DbContext domainModel.ConfigureEntityConventions(); domainModel.ConfigureDomainEventConventions(); + // Customizations domainModel.CustomizeIdentityConventions(context => { // Example: Use fixed-length strings with a binary collation for all string IIdentities @@ -477,7 +500,8 @@ internal sealed class MyDbContext : DbContext .UseCollation("Latin1_General_100_BIN2"); } }); - + + // Customizations domainModel.CustomizeWrapperValueObjectConventions(context => { // Example: Use DECIMAL(19, 9) for all decimal wrappers @@ -492,9 +516,10 @@ internal sealed class MyDbContext : DbContext } ``` -`ConfigureDomainModelConventions()` itself does not have any effect other than to invoke its action, which allows the specific mapping kinds to be chosen. +`ConfigureDomainModelConventions()` itself does not have any effect other than to invoke its lambda, which allows the specific mapping kinds to be chosen. The inner calls, such as to `ConfigureIdentityConventions()`, configure the various conventions. The `Customize*()` methods make it easy to specify your own conventions, such as for every identity or wrapper value object with a string at its core. +(This works even for nested ones, since both the direct underlying type and the core type are exposed.) Thanks to the provided conventions, no manual boilerplate mappings are needed, like conversions to primitives. Property-specific mappings are only needed where they are meaningful, such as the maximum length of a particular string property. @@ -545,11 +570,11 @@ 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 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`. Collection members form an exception to this rule. -The generators also provide structural equality for members that are of collection types, by comparing the elements. +The generators 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. +For `CompareTo()`, a structural implementation for collections is not supported: the generators will omit the `CompareTo()` method 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. @@ -561,7 +586,7 @@ Dictionary and lookup equality is similar to set equality when it comes to their 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. +As a best effort, these overloads work hard to return identical results to the generic overloads, at additional costs to efficiency. ## Testing @@ -579,7 +604,8 @@ To have source generators write a copy to a file for each generated piece of cod ### Debugging -Source generators can be debugged by enabling the following (outcommented) line in the `DomainModeling.Generator` project. To start debugging, rebuild and choose the current Visual Studio instance in the dialog that appears. +Source generators can be debugged by directly including the source `DomainModeling.Generator` project and enabling the following (outcommented) line there. +To start debugging, rebuild and choose the current Visual Studio instance in the dialog that appears. ```cs if (!System.Diagnostics.Debugger.IsAttached) System.Diagnostics.Debugger.Launch();