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.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/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.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.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs b/DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs new file mode 100644 index 0000000..556323a --- /dev/null +++ b/DomainModeling.Analyzer/Analyzers/ValueObjectImplicitConversionOnBinaryOperatorAnalyzer.cs @@ -0,0 +1,100 @@ +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 (OperandWasImplicitlyConvertedFromSomeIValueObject(leftTypeInfo) || OperandWasImplicitlyConvertedFromSomeIValueObject(rightTypeInfo)) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptor, + context.Node.GetLocation(), + binaryExpression.OperatorToken.ValueText, + 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 OperandWasImplicitlyConvertedFromSomeIValueObject(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 (to.IsNullable(out var nullableUnderlyingType) && nullableUnderlyingType.Equals(from, SymbolEqualityComparer.Default)) + return false; + + // Dig through nullables + if (from.IsNullable(out nullableUnderlyingType)) + from = 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 isConvertedFromSomeIValueObject = from.AllInterfaces.Any(interf => + interf is { Name: "IValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: 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.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 new file mode 100644 index 0000000..ab64a24 --- /dev/null +++ b/DomainModeling.Analyzer/DomainModeling.Analyzer.csproj @@ -0,0 +1,28 @@ + + + + netstandard2.0 + Architect.DomainModeling.Analyzer + Architect.DomainModeling.Analyzer + Enable + Enable + 13 + False + True + True + + + + + IDE0057 + + + + + + + + + + + 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/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 new file mode 100644 index 0000000..525d160 --- /dev/null +++ b/DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs @@ -0,0 +1,78 @@ +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 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 +{ + 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"), + equivalenceKey: "ImplementStringComparisonOrdinalGetter"); + 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"), + equivalenceKey: "ImplementStringComparisonOrdinalIgnoreCaseGetter"); + context.RegisterCodeFix(ordinalIgnoreCaseFix, diagnostic); + } + + private static Task AddStringComparisonMemberAsync( + Document document, + SyntaxNode root, + TypeDeclarationSyntax tds, + string stringComparisonExpression) + { + var newlineTrivia = root.GetNewlineTrivia(); + + 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)); + } +} 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..3c7a179 --- /dev/null +++ b/DomainModeling.CodeFixProviders/UnvalidatedEnumMemberAssignmentCodeFixer.cs @@ -0,0 +1,116 @@ +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; + +/// +/// 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 +{ + 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.Example/Currency.cs b/DomainModeling.Example/Currency.cs new file mode 100644 index 0000000..5a69c10 --- /dev/null +++ b/DomainModeling.Example/Currency.cs @@ -0,0 +1,29 @@ +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/Description.cs b/DomainModeling.Example/Description.cs deleted file mode 100644 index f4b6cb5..0000000 --- a/DomainModeling.Example/Description.cs +++ /dev/null @@ -1,24 +0,0 @@ -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 -{ - // For string wrappers, we must define how they are compared - protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; - - // Any component that we define manually is omitted by the generated code - // For example, we can explicitly define the Value property to have greater clarity, since it is quintessential - public string Value { get; 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 (ContainsNonWordCharacters(this.Value)) throw new ArgumentException("Nonsense."); - } -} diff --git a/DomainModeling.Example/DomainModeling.Example.csproj b/DomainModeling.Example/DomainModeling.Example.csproj index 3b6d12e..4c48d45 100644 --- a/DomainModeling.Example/DomainModeling.Example.csproj +++ b/DomainModeling.Example/DomainModeling.Example.csproj @@ -1,15 +1,15 @@ - + Exe - net6.0 + net9.0 Architect.DomainModeling.Example Architect.DomainModeling.Example Enable Enable False True - 12 + 13 @@ -19,7 +19,9 @@ + + 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 671f965..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}"); @@ -82,9 +80,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/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 564222a..8c917de 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs @@ -5,22 +5,27 @@ 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 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.IdTypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator.Args()); - """)); + var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.ValueWrappers + .Where(generatable => generatable.IsIdentity) + .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()); + """)); var source = $@" -using {Constants.DomainModelingNamespace}; +using Architect.DomainModeling; #nullable enable @@ -36,7 +41,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 ca0a471..37f9504 100644 --- a/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs +++ b/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs @@ -5,22 +5,27 @@ 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 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 {Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator.Args()); - """)); + var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.ValueWrappers + .Where(generatable => !generatable.IsIdentity) + .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()); + """)); var source = $@" -using {Constants.DomainModelingNamespace}; +using Architect.DomainModeling; #nullable enable @@ -36,7 +41,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 f7df66b..33d411b 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; @@ -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", @@ -135,26 +135,32 @@ 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; using System.Reflection; using System.Runtime.CompilerServices; -using {Constants.DomainModelingNamespace}; -using {Constants.DomainModelingNamespace}.Conversions; +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 namespace {ownAssemblyName} {{ + [CompilerGenerated] public static class EntityFrameworkDomainModelConfigurationExtensions {{ /// @@ -172,18 +178,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; }} @@ -193,15 +223,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; }} @@ -219,9 +273,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; @@ -240,13 +296,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 @@ -254,82 +348,311 @@ public interface IDomainModelConfigurator ModelConfigurationBuilder ConfigurationBuilder {{ get; }} }} + [CompilerGenerated] file sealed record class DomainModelConfigurator( ModelConfigurationBuilder ConfigurationBuilder) : IDomainModelConfigurator; - file sealed record class EntityFrameworkIdentityConfigurator(ModelConfigurationBuilder ConfigurationBuilder) - : {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator + [CompilerGenerated] + file sealed record class ValueWrapperConfigurator( + ModelConfigurationBuilder ConfigurationBuilder, + IDiagnosticsLogger DiagnosticLogger, + IDatabaseProvider? DatabaseProvider, + Action InvokeConfigurationCallbacks, + ValueWrapperConfigurationOptions? Options) + : IIdentityConfigurator, IWrapperValueObjectConfigurator, IModelInitializedConvention, IModelFinalizingConvention {{ - public void ConfigureIdentity<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity, TUnderlying>( - in {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator.Args _) - where TIdentity : IIdentity, ISerializableDomainObject + 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; + }} + }} + + [CompilerGenerated] + 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 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); + + // 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> + [CompilerGenerated] + private sealed class IdentityConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TProvider> : ValueConverter - where TModel : ISerializableDomainObject + where TModel : IValueWrapper {{ - public IdentityValueObjectConverter() + public IdentityConverter() : base( - DomainObjectSerializer.CreateSerializeExpression(), - DomainObjectSerializer.CreateDeserializeExpression(), - new ConverterMappingHints(precision: 28, scale: 0)) // For decimal IDs + model => DomainObjectSerializer.Serialize(model)!, + provider => DomainObjectSerializer.Deserialize(provider)!, + mappingHints: typeof(TProvider) == typeof(decimal) ? EntityFrameworkIdentityConfigurator.DecimalIdConverterMappingHints : null) {{ }} }} }} + [CompilerGenerated] file sealed record class EntityFrameworkWrapperValueObjectConfigurator( - ModelConfigurationBuilder ConfigurationBuilder) - : {Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator + ModelConfigurationBuilder ConfigurationBuilder, + WrapperValueObjectConfigurationOptions? Options = null) + : IWrapperValueObjectConfigurator {{ - public void ConfigureWrapperValueObject<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue>( - in {Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator.Args _) - where TWrapper : IWrapperValueObject, ISerializableDomainObject + public void ConfigureWrapperValueObject<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, TValue, TCore>( + in 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>(); + + // 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); + }} }} + [CompilerGenerated] 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)!, + mappingHints: null) {{ }} }} }} + [CompilerGenerated] file sealed record class EntityFrameworkEntityConfigurator( Action InvokeConfigurationCallbacks) - : {Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator, {Constants.DomainModelingNamespace}.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,41 +664,52 @@ 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 {Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator.Args args) + in IEntityConfigurator.Args args) where TEntity : IEntity {{ if (!this.EntityTypeConventionsByType.TryGetValue(typeof(TEntity), out var entityTypeConvention)) return; -#pragma warning disable EF1001 // Internal EF Core API usage -- No public APIs are available for this yet, and interceptors do not work because EF demands a usable ctor even the interceptor would prevent ctor usage +#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 = new UninitializedInstantiationBinding(typeof(TEntity), DomainObjectSerializer.CreateDeserializeExpression(typeof(TEntity))); + entityType.ConstructorBinding = UninitializedInstantiationBinding.Create(() => DomainObjectSerializer.Deserialize()); #pragma warning restore EF1001 // Internal EF Core API usage }} public void ConfigureDomainEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TDomainEvent>( - in {Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator.Args args) + in IDomainEventConfigurator.Args args) where TDomainEvent : IDomainObject {{ if (!this.EntityTypeConventionsByType.TryGetValue(typeof(TDomainEvent), out var entityTypeConvention)) return; -#pragma warning disable EF1001 // Internal EF Core API usage -- No public APIs are available for this yet, and interceptors do not work because EF demands a usable ctor even the interceptor would prevent ctor usage +#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 = new UninitializedInstantiationBinding(typeof(TDomainEvent), DomainObjectSerializer.CreateDeserializeExpression(typeof(TDomainEvent))); + entityType.ConstructorBinding = UninitializedInstantiationBinding.Create(() => DomainObjectSerializer.Deserialize()); #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))!; 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, @@ -383,15 +717,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) @@ -400,15 +734,102 @@ public override InstantiationBinding With(IReadOnlyList parame }} }} }} -}} -#endif + [CompilerGenerated] + 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) + {{ + }} + }} + + [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; }} + 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); + }} + }} + + [CompilerGenerated] + public sealed record class CustomizingWrapperValueObjectConfigurator( + ModelConfigurationBuilder ConfigurationBuilder, + Action Callback) + : IWrapperValueObjectConfigurator + {{ + [CompilerGenerated] + 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); + }} + }} +}} "; - AddSource(context, source, "EntityFrameworkDomainModelConfigurationExtensions", $"{Constants.DomainModelingNamespace}.EntityFramework"); + AddSource(context, source, "EntityFrameworkDomainModelConfigurationExtensions", $"Architect.DomainModeling.EntityFramework"); } - internal sealed record Generatable : IGeneratable + internal sealed record Generatable { public bool UsesEntityFrameworkConventions { get; set; } /// 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/DiagnosticReportingExtensions.cs b/DomainModeling.Generator/DiagnosticReportingExtensions.cs new file mode 100644 index 0000000..6691b39 --- /dev/null +++ b/DomainModeling.Generator/DiagnosticReportingExtensions.cs @@ -0,0 +1,53 @@ +using Architect.DomainModeling.Generator.Common; +using Microsoft.CodeAnalysis; + +namespace Architect.DomainModeling.Generator; + +/// +/// Defines extension methods on . +/// +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: 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. + /// + /// + private static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, Location? location) + { + context.ReportDiagnostic(Diagnostic.Create( + 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: 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 11ecd08..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; } @@ -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); @@ -47,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(attr => attr.IsOrInheritsClass("DomainEventAttribute", "Architect", "DomainModeling", arity: 0, out _)) is null) return null; // Only concrete @@ -65,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(), }; @@ -87,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; } @@ -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/DomainModeling.Generator.csproj b/DomainModeling.Generator/DomainModeling.Generator.csproj index 8c90020..94a55dc 100644 --- a/DomainModeling.Generator/DomainModeling.Generator.csproj +++ b/DomainModeling.Generator/DomainModeling.Generator.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -6,7 +6,7 @@ Architect.DomainModeling.Generator Enable Enable - 12 + 13 False True True @@ -22,11 +22,7 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + diff --git a/DomainModeling.Generator/DummyBuilderGenerator.cs b/DomainModeling.Generator/DummyBuilderGenerator.cs index 55768a0..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; } @@ -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); @@ -42,15 +44,15 @@ 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(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() { - 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 +93,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 +112,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,9 +120,9 @@ private static void GenerateSource(SourceProductionContext context, (ImmutableAr { context.CancellationToken.ThrowIfCancellationRequested(); - var type = compilation.GetTypeByMetadataName(builder.TypeFullyQualifiedName); - var modelType = type?.GetAttribute("DummyBuilderAttribute", Constants.DomainModelingNamespace, arity: 1) is AttributeData { AttributeClass: not null } attribute - ? attribute.AttributeClass.TypeArguments[0] + var type = compilation.GetTypeByMetadataName(builder.TypeFullMetadataName); + 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 @@ -135,7 +137,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 +145,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,55 +220,55 @@ 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))) 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));"); } @@ -280,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 @@ -294,14 +297,15 @@ 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} 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 ? "/*" : "")} @@ -334,10 +338,10 @@ namespace {containingNamespace} return result; } - private sealed record Builder : IGeneratable + private sealed record Builder { - 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/EntityGenerator.cs b/DomainModeling.Generator/EntityGenerator.cs index a677e5f..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; } @@ -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); @@ -47,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(attr => attr.IsOrInheritsClass("EntityAttribute", "Architect", "DomainModeling", out _)) is null) return null; // Only concrete @@ -65,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(), }; @@ -87,8 +89,8 @@ private static void GenerateSource(SourceProductionContext context, Generatable // Require the expected inheritance if (!generatable.IsEntity) { - context.ReportDiagnostic("EntityGeneratorUnexpectedInheritance", "Unexpected inheritance", - "Type marked as entity lacks IEntity interface.", DiagnosticSeverity.Warning, generatable.TypeLocation); + context.ReportDiagnostic("EntityGeneratorMissingInterface", "Missing IEntity interface", + "Type marked as entity lacks IEntity interface.", DiagnosticSeverity.Error, generatable.TypeLocation); return; } } @@ -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/EnumExtensions.cs b/DomainModeling.Generator/EnumExtensions.cs index 8084ff4..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; @@ -59,10 +58,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 +80,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 +115,17 @@ 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); + var result = 0UL; - span[0] = 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 ulongSpan[0]; + return result; } } 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 5992e1e..a247e0a 100644 --- a/DomainModeling.Generator/IdentityGenerator.cs +++ b/DomainModeling.Generator/IdentityGenerator.cs @@ -1,4 +1,4 @@ -using Architect.DomainModeling.Generator.Common; +using System.Collections.Immutable; using Architect.DomainModeling.Generator.Configurators; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -6,22 +6,157 @@ 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 + { + // 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: entityBaseType.TypeArguments[0], + underlyingType: entityBaseType.TypeArguments[1], + customCoreType: null), + // 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: attributeType.TypeArguments[0], + customCoreType: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[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> valueWrappers) { var provider = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, TransformSyntaxNode) .Where(generatable => generatable is not null) - .DeduplicatePartials(); + .DeduplicatePartials()!; + + context.RegisterSourceOutput(provider.Combine(valueWrappers), GenerateSource!); + + var aggregatedProvider = valueWrappers.Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); - context.RegisterSourceOutput(provider, GenerateSource!); + context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForIdentities); + } - var aggregatedProvider = provider - .Collect() - .Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + private static bool HasRelevantEntityAttribute(INamedTypeSymbol type, out INamedTypeSymbol attributeType) + { + 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 HasIdentityAttribute(INamedTypeSymbol type, out INamedTypeSymbol attributeType) + { + attributeType = null!; + if (type.GetAttribute(attr => attr.IsOrInheritsClass("IdentityValueObjectAttribute", "Architect", "DomainModeling", arity: 1, out _)) is { } attribute) + attributeType = attribute; + return attributeType is not null; + } - context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForIdentities!); + private static bool RequestsIdGenerationViaEntityBase(INamedTypeSymbol type, out INamedTypeSymbol entityBaseType) + { + var result = type.IsOrInheritsClass("Entity", "Architect", "DomainModeling", arity: 2, out entityBaseType); + return result; + } + + 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("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 + 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, "Design", severity, isEnabledByDefault: true), + type.Locations.FirstOrDefault()); + } } private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) @@ -30,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, Constants.EntityTypeName)) - 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; @@ -50,6 +186,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,25 +198,37 @@ private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancella return null; ITypeSymbol underlyingType; - var isBasedOnEntity = type.IsOrInheritsClass(baseType => baseType.Name == Constants.EntityTypeName, out _); - // Path A: An Entity subclass that might be an Entity for which TId may have to be generated - 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 (!type.IsOrInheritsClass(baseType => baseType.Arity == 2 && baseType.IsType(Constants.EntityTypeName, Constants.DomainModelingNamespace), out var entityType)) + // Only an actual Entity + if (!RequestsIdGenerationViaEntityBase(type, out var entityBaseType)) return null; - var idType = entityType.TypeArguments[0]; - underlyingType = entityType.TypeArguments[1]; - result.EntityTypeName = type.Name; - result.EntityTypeLocation = type.Locations.FirstOrDefault(); + 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; if (result.IdTypeExists) + { + // Entity was needlessly used, with a preexisting TId + 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; + } result.IsStruct = true; result.ContainingNamespace = type.ContainingNamespace.ToString(); @@ -88,19 +238,45 @@ 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 (type.GetAttribute("IdentityValueObjectAttribute", Constants.DomainModelingNamespace, arity: 1) is not AttributeData { AttributeClass: not null } attribute) - return null; + var idType = attributeType.TypeArguments[0]; + underlyingType = attributeType.TypeArguments[1]; + + // 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; - underlyingType = attribute.AttributeClass.TypeArguments[0]; + // 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.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.IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); result.IsRecord = type.IsRecord; result.IsStruct = type.TypeKind == TypeKind.Struct; @@ -124,179 +300,209 @@ 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 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(!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 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 && - method.Parameters[0].Type.IsType())); + 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(!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.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.Name == "op_Equality" && member is IMethodSymbol method && method.Arity == 0 && 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 |= IdTypeComponents.NotEqualsOperator.If(members.Any(member => - member.Name == "op_Inequality" && member is IMethodSymbol method && method.Arity == 0 && 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 |= IdTypeComponents.GreaterThanOperator.If(members.Any(member => - member.Name == "op_GreaterThan" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= IdTypeComponents.LessThanOperator.If(members.Any(member => - member.Name == "op_LessThan" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= IdTypeComponents.GreaterEqualsOperator.If(members.Any(member => - member.Name == "op_GreaterThanOrEqual" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= IdTypeComponents.LessEqualsOperator.If(members.Any(member => - member.Name == "op_LessThanOrEqual" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); 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 { 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.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && + 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.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && - method.ReturnType.IsType(nameof(Nullable), "System") && method.ReturnType.HasSingleGenericTypeArgument(type) && - (underlyingType.IsReferenceType - ? method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default) - : method.Parameters[0].Type.IsType(nameof(Nullable), "System") && method.Parameters[0].Type.HasSingleGenericTypeArgument(underlyingType)))); + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && + method.ReturnType.IsNullableOf(type) && + method.Parameters[0].Type.IsNullableOrReferenceOf(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 && - (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))); + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && + method.ReturnType.IsNullableOrReferenceOf(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 { Arity: 0, IsStatic: false, Parameters.Length: 0, } method && + method.ReturnType.Equals(underlyingType, SymbolEqualityComparer.Default))); existingComponents |= IdTypeComponents.DeserializeFromUnderlying.If(members.Any(member => - member.Name.EndsWith($".{Constants.DeserializeDomainObjectMethodName}") && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 1 && - method.Parameters[0].Type.Equals(underlyingType, SymbolEqualityComparer.Default))); + 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?.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")); + member is IPropertySymbol { Name: "StringComparison", IsImplicitlyDeclared: false, } prop)); 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())); + 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.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)); + 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.Name == "Parse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType())); + 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.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())); + 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 |= IdTypeComponents.SpanParsableTryParseMethod.If(members.Any(member => - member.Name == "TryParse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && - method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && - method.Parameters[1].Type.IsType(typeof(IFormatProvider)) && + 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.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)))); + 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.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())); + 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 |= IdTypeComponents.Utf8SpanParsableTryParseMethod.If(members.Any(member => - member.Name == "TryParse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 3 && - method.Parameters[0].Type.IsType(typeof(ReadOnlySpan)) && - method.Parameters[1].Type.IsType(typeof(IFormatProvider)) && + 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.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)))); + 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 { Arity: 0, IsStatic: true, Parameters.Length: 1, } method && + member.HasNameOrExplicitInterfaceImplementationName("Create") && + 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.UnderlyingTypeFullyQualifiedName = underlyingType.ToString(); - result.UnderlyingTypeIsToStringNullable = underlyingType.IsToStringNullable(); - result.UnderlyingTypeIsINumber = underlyingType.IsOrImplementsInterface(interf => interf.IsType("INumber", "System.Numerics", arity: 1), out _); - result.UnderlyingTypeIsString = underlyingType.IsType(); - result.UnderlyingTypeIsNonNullString = result.UnderlyingTypeIsString && underlyingType.NullableAnnotation != NullableAnnotation.Annotated; - result.UnderlyingTypeIsNumericUnsuitableForJson = underlyingType.IsType() || underlyingType.IsType() || underlyingType.IsType() || underlyingType.IsType() || - underlyingType.IsType("UInt128", "System") || underlyingType.IsType("Int128", "System"); - result.UnderlyingTypeIsStruct = underlyingType.IsValueType; - result.ToStringExpression = underlyingType.CreateStringExpression("Value"); + result.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.IsSystemType("INumber", "Numerics", arity: 1), out _); + result.UnderlyingTypeIsString = underlyingType.SpecialType == SpecialType.System_String; + result.UnderlyingTypeIsNonNullString = result.UnderlyingTypeIsString && underlyingType.NullableAnnotation != NullableAnnotation.Annotated; + 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; } - - 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; + + 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; - var entityTypeName = generatable.EntityTypeName; var underlyingTypeIsStruct = generatable.UnderlyingTypeIsStruct; var isRecord = generatable.IsRecord; var isINumber = generatable.UnderlyingTypeIsINumber; var isString = generatable.UnderlyingTypeIsString; - var isToStringNullable = generatable.UnderlyingTypeIsToStringNullable; + var isToStringNullable = generatable.IsToStringNullable; var toStringExpression = generatable.ToStringExpression; var hashCodeExpression = generatable.HashCodeExpression; var equalityExpression = generatable.EqualityExpression; @@ -304,75 +510,25 @@ private static void GenerateSource(SourceProductionContext context, Generatable var accessibility = generatable.Accessibility; var existingComponents = generatable.ExistingComponents; - var hasIdentityValueObjectAttribute = generatable.IdTypeExists; - - 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 summary = entityTypeName is null ? null : $@" - /// - /// The identity type used for the entity. - /// "; + var idTypeExists = generatable.IdTypeExists; + + var directParentOfCore = ValueWrapperGenerator.GetDirectParentOfCoreType(valueWrappers, idTypeName, containingNamespace); + var coreTypeFullyQualifiedName = directParentOfCore.CoreTypeFullyQualifiedName ?? generatable.UnderlyingTypeFullyQualifiedName; + var coreTypeIsStruct = directParentOfCore.CoreTypeIsStruct; + + (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; // 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 @@ -387,41 +543,39 @@ 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; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using {Constants.DomainModelingNamespace}; -using {Constants.DomainModelingNamespace}.Conversions; +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 namespace {containingNamespace} {{ - {summary} - - {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteJsonConverterAttribute(idTypeName)} - {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "*/" : "")} - - {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(idTypeName)} - {(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}>, + {(existingComponents.HasFlags(IdTypeComponents.SystemTextJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} + {(existingComponents.HasFlags(IdTypeComponents.NewtonsoftJsonConverter) ? "//" : "")}{JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(idTypeName, underlyingTypeFullyQualifiedName, numericAsString: underlyingTypeIsNumericUnsuitableForJson)} + {(idTypeExists ? "" : $"[IdentityValueObject<{underlyingTypeFullyQualifiedName}>]")} + [DebuggerDisplay(""{{ToString(){(coreTypeFullyQualifiedName == "string" ? "" : ",nq")}}}"")] + [CompilerGenerated] {accessibility.ToCodeString()} readonly{(idTypeExists ? " partial" : "")}{(isRecord ? " record" : "")} struct {idTypeName} : + IIdentity<{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}> + {(isSpanFormattable ? "" : "//")}ISpanFormattable, ISpanFormattable{formattableParsableWrapperSuffix}, + {(isSpanParsable ? "" : "//")}ISpanParsable<{idTypeName}>, ISpanParsable{formattableParsableWrapperSuffix}, + {(isUtf8SpanFormattable ? "" : "//")}IUtf8SpanFormattable, IUtf8SpanFormattable{formattableParsableWrapperSuffix}, + {(isUtf8SpanParsable ? "" : "//")}IUtf8SpanParsable<{idTypeName}>, IUtf8SpanParsable{formattableParsableWrapperSuffix}, + IDirectValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>, + ICoreValueWrapper<{idTypeName}, {coreTypeFullyQualifiedName}> {{ {(existingComponents.HasFlags(IdTypeComponents.Value) ? "/*" : "")} {nonNullStringSummary} @@ -482,138 +636,171 @@ 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.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 < 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) + {{ + 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() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + {underlyingTypeFullyQualifiedName}{(underlyingTypeIsStruct || isNonNullString ? "" : "?")} IValueWrapper<{idTypeName}, {underlyingTypeFullyQualifiedName}>.Serialize() {{ return this.Value; }} {(existingComponents.HasFlags(IdTypeComponents.SerializeToUnderlying) ? "*/" : "")} {(existingComponents.HasFlags(IdTypeComponents.DeserializeFromUnderlying) ? "/*" : "")} -#if NET7_0_OR_GREATER /// - /// Deserializes a plain value back into a domain object, without any validation. + /// Deserializes a plain value back into a domain object, without using a parameterized constructor. /// - static {idTypeName} {Constants.SerializableDomainObjectInterfaceTypeName}<{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 Unsafe.As<{underlyingTypeFullyQualifiedName}, {idTypeName}>(ref value);" : "")} + {(existingComponents.HasFlags(IdTypeComponents.UnsettableValue) ? "//" : "")}return new {idTypeName}() {{ Value = value }}; }} -#endif {(existingComponents.HasFlags(IdTypeComponents.DeserializeFromUnderlying) ? "*/" : "")} - {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "/*" : "")} - public static bool operator ==({idTypeName} left, {idTypeName} right) => left.Equals(right); - {(existingComponents.HasFlags(IdTypeComponents.EqualsOperator) ? "*/" : "")} - {(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) ? "*/" : "")} + {(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 => this.Value is {{ }} actual ? ValueWrapperUnwrapper.Unwrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(actual) : default; + + [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() + {{ + var intermediateValue = DomainObjectSerializer.Serialize<{idTypeName}, {underlyingTypeFullyQualifiedName}>(this); + return DomainObjectSerializer.Serialize<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(intermediateValue); + }} + + /// + /// 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) ? "*/" : "")} + + #endregion #region Formatting & Parsing -#if NET7_0_OR_GREATER +//#if !NET10_0_OR_GREATER // Starting with .NET 10, these operations are provided by default implementations and extension methods - {(existingComponents.HasFlags(IdTypeComponents.FormattableToStringOverride) ? "/*" : "")} + {(!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) ? "*/" : "")} - -#endif - -#if NET8_0_OR_GREATER + {(!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 +//#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) ? "*/" : "")} }} }} "; @@ -649,7 +836,6 @@ internal enum IdTypeComponents : ulong SerializeToUnderlying = 1UL << 20, DeserializeFromUnderlying = 1UL << 21, UnsettableValue = 1UL << 22, - FormattableToStringOverride = 1UL << 24, ParsableTryParseMethod = 1UL << 25, ParsableParseMethod = 1UL << 26, @@ -659,13 +845,15 @@ internal enum IdTypeComponents : ulong Utf8SpanFormattableTryFormatMethod = 1UL << 30, Utf8SpanParsableTryParseMethod = 1UL << 31, Utf8SpanParsableParseMethod = 1UL << 32, + CreateMethod = 1UL << 33, + DirectValueWrapperInterface = 1UL << 34, + CoreValueWrapperInterface = 1UL << 35, } - 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); } - 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); } @@ -675,21 +863,20 @@ 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); } 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 bool UnderlyingTypeIsInterface { 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; } + public Diagnostic? Problem { get; set; } } } diff --git a/DomainModeling.Generator/JsonSerializationGenerator.cs b/DomainModeling.Generator/JsonSerializationGenerator.cs index 5fc0919..643d033 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" : "")}ValueWrapperJsonConverter<{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({(numericAsString ? "LargeNumber" : "")}ValueWrapperNewtonsoftJsonConverter<{modelTypeName}, {underlyingTypeFullyQualifiedName}>))]"; } } 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/SourceProductionContextExtensions.cs b/DomainModeling.Generator/SourceProductionContextExtensions.cs deleted file mode 100644 index 58748b7..0000000 --- a/DomainModeling.Generator/SourceProductionContextExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Architect.DomainModeling.Generator.Common; -using Microsoft.CodeAnalysis; - -namespace Architect.DomainModeling.Generator; - -/// -/// Defines extension methods on . -/// -internal static class SourceProductionContextExtensions -{ - /// - /// Shorthand extension method to report a diagnostic, with less boilerplate code. - /// - public static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, ISymbol? symbol = null) - { - context.ReportDiagnostic(id, title, description, severity, symbol?.Locations.FirstOrDefault()); - } - - /// - /// Shorthand extension method to report a diagnostic, with less boilerplate code. - /// - public static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, Location? location) - { - context.ReportDiagnostic(Diagnostic.Create( - new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true), - location)); - } - - /// - /// Shorthand extension method to report a diagnostic, with less boilerplate code. - /// - public static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, SimpleLocation? location) - { - context.ReportDiagnostic(Diagnostic.Create( - new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true), - location)); - } -} diff --git a/DomainModeling.Generator/SymbolExtensions.cs b/DomainModeling.Generator/SymbolExtensions.cs new file mode 100644 index 0000000..2da6033 --- /dev/null +++ b/DomainModeling.Generator/SymbolExtensions.cs @@ -0,0 +1,25 @@ +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); + + 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/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 ab255e8..8733e23 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; @@ -10,47 +11,228 @@ internal static class TypeSymbolExtensions { private const string ComparisonsNamespace = "Architect.DomainModeling.Comparisons"; - private static IReadOnlyCollection ConversionOperatorNames { get; } = ["op_Implicit", "op_Explicit",]; - /// - /// Returns whether the is of type . + /// Returns the full CLR metadata name of the , e.g. "Namespace.Type+NestedGenericType`1". /// - public static bool IsType(this ITypeSymbol typeSymbol) + public static string GetFullMetadataName(this INamedTypeSymbol namedTypeSymbol) { - return typeSymbol.IsType(typeof(T)); + // 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 { 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; + } + + /// 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 = + typeSymbol.Name == typeName && + typeSymbol.ContainingNamespace is + { + ContainingNamespace: + { + ContainingNamespace.IsGlobalNamespace: true, + } ns1 + } ns2 && + ns1.Name == namespaceComponent1 && + ns2.Name == namespaceComponent2; + 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 = + 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; + } + + /// 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 = + 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; + } + + /// 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 = + 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 . /// - [Obsolete("Use ITypeSymbol.Equals(ITypeSymbol, SymbolEqualityComparer) instead.")] - public static bool IsType(this ITypeSymbol typeSymbol, ITypeSymbol comparand) + public static bool IsTypeWithNamespace(this ITypeSymbol typeSymbol, string typeName, string containingNamespace, int? arity = null) { - var containingNamespace = comparand.ContainingNamespace; - - Span freeBuffer = stackalloc char[128]; - ReadOnlySpan chars = freeBuffer; + return IsTypeWithNamespace(typeSymbol, typeName.AsSpan(), containingNamespace.AsSpan(), arity); + } - while (containingNamespace?.IsGlobalNamespace == false && freeBuffer.Length >= containingNamespace.Name.Length) - { - containingNamespace.Name.AsSpan().CopyTo(freeBuffer); - freeBuffer = freeBuffer.Slice(containingNamespace.Name.Length); - containingNamespace = containingNamespace.ContainingNamespace; - } + /// + /// Returns whether the has the given and . + /// + /// 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 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)); } /// @@ -60,7 +242,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); @@ -88,49 +270,185 @@ 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 result = typeSymbol.IsSystemType("Span") && typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.TypeArguments[0].SpecialType == specialType; + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsReadOnlySpanOfSpecialType(this ITypeSymbol typeSymbol, SpecialType specialType) { - var fullTypeNameSpan = fullTypeName.AsSpan(); + var result = typeSymbol.IsSystemType("ReadOnlySpan") && typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.TypeArguments[0].SpecialType == specialType; + return result; + } - var lastDotIndex = fullTypeNameSpan.LastIndexOf('.'); + /// 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."); - if (lastDotIndex < 1) return false; + 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; + } - var typeName = fullTypeNameSpan.Slice(1 + lastDotIndex); - var containingNamespace = fullTypeNameSpan.Slice(0, lastDotIndex); + typeSymbol = typeSymbol.BaseType!; + } - return IsType(typeSymbol, typeName, containingNamespace, arity); + targetType = null!; + return false; } - /// - /// Returns whether the has the given and . - /// - public static bool IsType(this ITypeSymbol typeSymbol, string typeName, string containingNamespace, int? arity = null) + /// 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) { - return IsType(typeSymbol, typeName.AsSpan(), containingNamespace.AsSpan(), arity); + 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; } - /// - /// 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) + /// 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) { - var backtickIndex = typeName.IndexOf('`'); - if (backtickIndex >= 0) - typeName = typeName.Slice(0, backtickIndex); + 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; + } - var result = typeSymbol.Name.AsSpan().Equals(typeName, StringComparison.Ordinal) && - typeSymbol.ContainingNamespace.HasFullName(containingNamespace); + typeSymbol = typeSymbol.BaseType!; + } - if (result && arity is not null) - result = typeSymbol is INamedTypeSymbol namedTypeSymbol && namedTypeSymbol.Arity == arity; + targetType = null!; + return false; + } - 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 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; } /// @@ -146,12 +464,8 @@ public static bool IsOrInheritsClass(this ITypeSymbol typeSymbol, Func()) - break; - if (predicate(baseType)) { targetType = baseType; @@ -190,41 +504,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; @@ -234,22 +530,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; } @@ -257,13 +551,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; @@ -272,17 +567,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; @@ -293,11 +590,37 @@ public static bool IsNullable(this ITypeSymbol typeSymbol, out ITypeSymbol under } /// - /// Returns whether the given implements against itself. + /// Returns whether the is a with T matching . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNullableOf(this ITypeSymbol typeSymbol, ITypeSymbol underlyingType) + { + var result = IsNullable(typeSymbol, out var comparand) && underlyingType.Equals(comparand, SymbolEqualityComparer.Default); + 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. /// - public static bool IsSelfEquatable(this ITypeSymbol typeSymbol) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsNullableOfOrEqualTo(this ITypeSymbol typeSymbol, ITypeSymbol underlyingType) { - return typeSymbol.IsOrImplementsInterface(interf => interf.IsType("IEquatable", "System", arity: 1) && interf.HasSingleGenericTypeArgument(typeSymbol), out _); + var result = underlyingType.Equals(typeSymbol, SymbolEqualityComparer.Default) || + (IsNullable(typeSymbol, out var comparand) && underlyingType.Equals(comparand, SymbolEqualityComparer.Default)); + return result; } /// @@ -315,74 +638,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) + if (typeSymbol is IArrayTypeSymbol { Rank: 1, ElementType: INamedTypeSymbol arrayElementType }) // Single-dimensional, non-nested array { - 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)) - { - 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; + } } /// @@ -391,94 +710,105 @@ 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; } /// - /// 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.IsType(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; } /// /// 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 => - member is IMethodSymbol method && ConversionOperatorNames.Contains(method.Name) && member.DeclaredAccessibility == Accessibility.Public && - method.ReturnType.IsType(typeName, containingNamespace)); + var result = typeSymbol.SpecialType != specialType && typeSymbol.GetMembers().Any(member => + member is IMethodSymbol { Name: WellKnownMemberNames.ExplicitConversionName or WellKnownMemberNames.ImplicitConversionName, DeclaredAccessibility: Accessibility.Public, } method && + 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 => - member is IMethodSymbol method && ConversionOperatorNames.Contains(method.Name) && member.DeclaredAccessibility == Accessibility.Public && - method.Parameters.Length == 1 && method.Parameters[0].Type.IsType(typeName, containingNamespace)); + var result = typeSymbol.SpecialType != specialType && typeSymbol.GetMembers().Any(member => + member is IMethodSymbol { Name: WellKnownMemberNames.ExplicitConversionName or WellKnownMemberNames.ImplicitConversionName, DeclaredAccessibility: Accessibility.Public, Parameters.Length: 1, } method && + 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(SpecialType.System_Boolean)) yield return (SpecialType.System_Boolean, typeof(bool)); - if (typeSymbol.HasConversionFrom("Boolean", "System")) yield return typeof(bool); + 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)); + } - 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); + /// + /// 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 string expression of the given of "this". + /// 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() + }; } /// @@ -488,7 +818,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 @@ -505,39 +835,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); - 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})"; + var typeOrNullableUnderlying = typeSymbol.IsNullable(out var nullableUnderlyingType) + ? nullableUnderlyingType + : typeSymbol; + + 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 (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 ((!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})"; } - if (typeSymbol.IsValueType && !typeSymbol.IsNullable()) return $"this.{memberName}.GetHashCode()"; + if (typeSymbol.IsValueType && nullableUnderlyingType is null) return $"this.{memberName}.GetHashCode()"; return $"(this.{memberName}?.GetHashCode() ?? 0)"; } @@ -551,45 +875,41 @@ 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.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)"; } @@ -604,11 +924,11 @@ 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) - 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}))"; @@ -654,22 +974,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)) - 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.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/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 d118a4e..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; } @@ -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; @@ -41,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(attr => attr.IsOrInheritsClass("ValueObjectAttribute", "Architect", "DomainModeling", arity: 0, out _)) 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; @@ -52,6 +54,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,61 +66,63 @@ 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 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(!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 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 && - method.Parameters[0].Type.IsType())); + 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(!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.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))); 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("ValueObject", "Architect", "DomainModeling", arity: 0, out _)); result.ExistingComponents = existingComponents; @@ -139,7 +144,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; @@ -152,7 +157,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) @@ -165,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; } @@ -182,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) { @@ -231,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(); @@ -260,19 +269,19 @@ private static void GenerateSource(SourceProductionContext context, (Generatable using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using {Constants.DomainModelingNamespace}; +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} : + IValueObject, + IEquatable<{typeName}>{(isComparable ? "" : "/*")}, + IComparable<{typeName}>{(isComparable ? "" : "*/")} {{ - {(isRecord || existingComponents.HasFlags(ValueObjectTypeComponents.StringComparison) ? "/*" : "")} - {(dataMembers.Any(member => member.Type.IsType()) - ? @"protected sealed override StringComparison StringComparison => StringComparison.Ordinal;" - : @"protected sealed override StringComparison StringComparison => throw new NotSupportedException(""This operation applies to string-based value objects only."");")} - {(isRecord || existingComponents.HasFlags(ValueObjectTypeComponents.StringComparison) ? "*/" : "")} + {stringComparisonProperty} {(existingComponents.HasFlags(ValueObjectTypeComponents.DefaultConstructor) ? "/*" : "")} #pragma warning disable CS8618 // Deserialization constructor @@ -317,56 +326,25 @@ 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) ? "*/" : "")} - {(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 ? "" : "*/")} }} }} @@ -417,9 +395,10 @@ private enum ValueObjectTypeComponents : ulong LessEqualsOperator = 1 << 12, StringComparison = 1 << 13, DefaultConstructor = 1 << 14, + ValueObjectBaseClass = 1 << 15, } - private sealed record Generatable : IGeneratable + private sealed record Generatable { public bool IsValueObject { get; set; } public bool IsPartial { get; set; } @@ -429,6 +408,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/ValueWrapperGenerator.cs b/DomainModeling.Generator/ValueWrapperGenerator.cs new file mode 100644 index 0000000..d10d92b --- /dev/null +++ b/DomainModeling.Generator/ValueWrapperGenerator.cs @@ -0,0 +1,212 @@ +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +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, valueWrappers); + this.WrapperValueObjectGenerator.Generate(context, valueWrappers); + } + + /// + /// 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) + { + // 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]; + + ref readonly var result = ref Unsafe.NullRef(); + + 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 = ref item; + nextTypeName = item.CoreTypeFullyQualifiedName.AsSpan(); + break; + } + } + } while (couldDigDeeper); + + 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.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 + /// + /// 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 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; + var isUtf8SpanParsable = false; + + // 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)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; + nextTypeName = item.UnderlyingTypeFullyQualifiedName.AsSpan(); + coreValueCouldBeNull |= item.CoreValueCouldBeNull; + isSpanFormattable |= item.IsSpanFormattable; + isSpanParsable |= item.IsSpanParsable; + isUtf8SpanFormattable |= item.IsUtf8SpanFormattable; + isUtf8SpanParsable |= item.IsUtf8SpanParsable; + break; + } + } + } while (couldDigDeeper && (isSpanFormattable & isSpanParsable & isUtf8SpanFormattable & isUtf8SpanParsable) == false); // Possible & worth seeking deeper + + return (!coreValueCouldBeNull, 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; } + /// + /// Helps implement wrappers around unofficial wrapper types, such as a WrapperValueObject<Uri> that pretends its core type is . + /// + 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. + /// + public bool CoreValueCouldBeNull { get; } + public bool IsSpanFormattable { get; } + public bool IsSpanParsable { get; } + public bool IsUtf8SpanFormattable { get; } + public bool IsUtf8SpanParsable { get; } + + public BasicGeneratable( + bool isIdentity, + string containingNamespace, + ITypeSymbol wrapperType, + ITypeSymbol underlyingType, + ITypeSymbol? customCoreType) + { + 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.CoreTypeFullyQualifiedName = coreType.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 834310f..7b85db8 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,120 @@ 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.TypeArguments[0] is ITypeSymbol underlyingType => + GetFirstProblem((TypeDeclarationSyntax)context.Node, type, underlyingType) is { } + ? default + : new ValueWrapperGenerator.BasicGeneratable( + isIdentity: false, + containingNamespace: type.ContainingNamespace.ToString(), + wrapperType: type, + underlyingType: underlyingType, + customCoreType: type.AllInterfaces.FirstOrDefault(interf => interf.IsType("ICoreValueWrapper", "Architect", "DomainModeling", arity: 2))?.TypeArguments[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> 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 = valueWrappers.Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + + context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForWrapperValueObjects); + } + + private static bool HasRequiredAttribute(INamedTypeSymbol type, out INamedTypeSymbol attributeType) + { + 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) + { + 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("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 + 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 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); + } - var aggregatedProvider = provider - .Collect() - .Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context)); + return null; - context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForWrapperValueObjects!); + // Local shorthand to create a diagnostic + Diagnostic CreateDiagnostic(string id, string title, string description, DiagnosticSeverity severity) + { + return Diagnostic.Create( + new DiagnosticDescriptor(id, title, description, "Design", severity, isEnabledByDefault: true), + type.Locations.FirstOrDefault()); + } } private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default) @@ -30,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; } @@ -39,6 +138,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,15 +148,13 @@ 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.TypeArguments[0]; var result = new Generatable(); - result.TypeLocation = type.Locations.FirstOrDefault(); - result.IsWrapperValueObject = type.IsOrImplementsInterface(type => type.IsType(Constants.WrapperValueObjectInterfaceTypeName, Constants.DomainModelingNamespace, arity: 1), out _); - result.IsSerializableDomainObject = type.IsOrImplementsInterface(type => type.IsType(Constants.SerializableDomainObjectInterfaceTypeName, Constants.DomainModelingNamespace, arity: 2), out _); + result.IsWrapperValueObject = type.IsOrImplementsInterface(type => type.IsType("IWrapperValueObject", "Architect", "DomainModeling", arity: 1), out _); result.IsPartial = tds.Modifiers.Any(SyntaxKind.PartialKeyword); result.IsRecord = type.IsRecord; result.IsClass = type.TypeKind == TypeKind.Class; @@ -67,20 +166,22 @@ 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.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)"); 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.UnderlyingTypeIsString = underlyingType.SpecialType == SpecialType.System_String; + result.UnderlyingTypeIsInterface = underlyingType.TypeKind == TypeKind.Interface; - 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.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(attr => attr.IsOrInheritsClass("IdentityValueObjectAttribute", "Architect", "DomainModeling", arity: 1, out _)) is not null; var members = type.GetMembers(); @@ -93,234 +194,242 @@ 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)); // 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 is IMethodSymbol { Name: nameof(ToString), IsImplicitlyDeclared: false, IsOverride: true, 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 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 && - method.Parameters[0].Type.IsType())); + 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(!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.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.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 |= WrapperValueObjectTypeComponents.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 |= WrapperValueObjectTypeComponents.GreaterThanOperator.If(members.Any(member => - member.Name == "op_GreaterThan" && member is IMethodSymbol method && method.Parameters.Length == 2 && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= WrapperValueObjectTypeComponents.LessThanOperator.If(members.Any(member => - member.Name == "op_LessThan" && member is IMethodSymbol method && method.Parameters.Length == 2 && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOperatorName, IsStatic: true, Parameters.Length: 2, } method && + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= WrapperValueObjectTypeComponents.GreaterEqualsOperator.If(members.Any(member => - member.Name == "op_GreaterThanOrEqual" && member is IMethodSymbol method && method.Parameters.Length == 2 && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.GreaterThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); existingComponents |= WrapperValueObjectTypeComponents.LessEqualsOperator.If(members.Any(member => - member.Name == "op_LessThanOrEqual" && member is IMethodSymbol method && method.Parameters.Length == 2 && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[1].Type.Equals(type, SymbolEqualityComparer.Default))); + member is IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator, Name: WellKnownMemberNames.LessThanOrEqualOperatorName, IsStatic: true, Parameters.Length: 2, } method && + method.Parameters[0].Type.IsNullableOfOrEqualTo(type) && + method.Parameters[1].Type.IsNullableOfOrEqualTo(type))); 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 { 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.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && + 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.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && - method.ReturnType.Equals(type, SymbolEqualityComparer.Default) && - method.Parameters[0].Type.IsType(nameof(Nullable), "System") && method.Parameters[0].Type.HasSingleGenericTypeArgument(underlyingType))); + existingComponents |= WrapperValueObjectTypeComponents.NullableConvertToOperator.If(members.Any(member => + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && + 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 => - (member.Name == "op_Implicit" || member.Name == "op_Explicit") && member is IMethodSymbol method && method.Parameters.Length == 1 && - method.ReturnType.IsType(nameof(Nullable), "System") && method.ReturnType.HasSingleGenericTypeArgument(underlyingType) && - method.Parameters[0].Type.Equals(type, SymbolEqualityComparer.Default))); + existingComponents |= WrapperValueObjectTypeComponents.NullableConvertFromOperator.If(members.Any(member => + member is IMethodSymbol { MethodKind: MethodKind.Conversion, IsStatic: true, Parameters.Length: 1, } method && + method.ReturnType.IsNullableOrReferenceOf(underlyingType) && + method.Parameters[0].Type.IsNullableOrReferenceOf(type))); existingComponents |= WrapperValueObjectTypeComponents.SerializeToUnderlying.If(members.Any(member => - member.Name.EndsWith($".{Constants.SerializeDomainObjectMethodName}") && 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.Name.EndsWith($".{Constants.DeserializeDomainObjectMethodName}") && 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?.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)); + member is IPropertySymbol { Name: "StringComparison", IsImplicitlyDeclared: false, } prop)); 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())); + 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.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)); + 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.Name == "Parse" && member is IMethodSymbol method && method.Arity == 0 && method.Parameters.Length == 2 && - method.Parameters[0].Type.IsType() && method.Parameters[1].Type.IsType())); + 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.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())); + 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.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)) && + 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.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)))); + 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.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())); + 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.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)) && + 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.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)))); + 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 { Arity: 0, IsStatic: true, Parameters.Length: 1, } method && + member.HasNameOrExplicitInterfaceImplementationName("Create") && + 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))); + + existingComponents |= WrapperValueObjectTypeComponents.WrapperBaseClass.If(type.IsOrInheritsClass("WrapperValueObject", "Architect", "DomainModeling", arity: 1, out _)); 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, } }); + + result.Problem = GetFirstProblem(tds, type, underlyingType); return result; } - private static void GenerateSource(SourceProductionContext context, Generatable generatable) + private static void GenerateSource(SourceProductionContext context, (Generatable Generatable, ImmutableArray ValueWrappers) input) { context.CancellationToken.ThrowIfCancellationRequested(); - // 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; - } - - // 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); - return; - } - - // No source generation, only above analyzers - if (!generatable.IsPartial) - return; + var generatable = input.Generatable; + var valueWrappers = input.ValueWrappers; - // 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; - } + if (generatable.Problem is not null) + context.ReportDiagnostic(generatable.Problem); - // 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); + if (generatable.Problem is not null || !generatable.IsPartial) return; - } - - // Only if non-abstract - if (generatable.IsAbstract) - { - context.ReportDiagnostic("WrapperValueObjectGeneratorAbstractType", "Source-generated abstract type", - "The type was not source-generated because it is abstract. 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 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.CoreTypeFullyQualifiedName ?? 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}>"; + // 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); @@ -328,39 +437,33 @@ private static void GenerateSource(SourceProductionContext context, Generatable var source = $@" using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using {Constants.DomainModelingNamespace}; -using {Constants.DomainModelingNamespace}.Conversions; +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 namespace {containingNamespace} {{ - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteJsonConverterAttribute(typeName)} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.SystemTextJsonConverter) ? "*/" : "")} - - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "/*" : "")} - {JsonSerializationGenerator.WriteNewtonsoftJsonConverterAttribute(typeName)} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.NewtonsoftJsonConverter) ? "*/" : "")} - - /* Generated */ {generatable.Accessibility.ToCodeString()} sealed partial{(generatable.IsRecord ? " record" : "")} class {typeName} - : {Constants.WrapperValueObjectTypeName}<{underlyingTypeFullyQualifiedName}>, + {(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}>, - {(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}> + {(isComparable ? "" : "//")}IComparable<{typeName}>, + {(isSpanFormattable ? "" : "//")}ISpanFormattable, ISpanFormattable{formattableParsableWrapperSuffix}, + {(isSpanParsable ? "" : "//")}ISpanParsable<{typeName}>, ISpanParsable{formattableParsableWrapperSuffix}, + {(isUtf8SpanFormattable ? "" : "//")}IUtf8SpanFormattable, IUtf8SpanFormattable{formattableParsableWrapperSuffix}, + {(isUtf8SpanParsable ? "" : "//")}IUtf8SpanParsable<{typeName}>, IUtf8SpanParsable{formattableParsableWrapperSuffix}, + 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; }} @@ -373,17 +476,31 @@ 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}([DisallowNull] {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 sealed override string{(generatable.UnderlyingTypeHasNullableToString ? "?" : "")} ToString() + public {(generatable.IsClass ? "sealed " : "")}override string{(generatable.IsToStringNullable ? "?" : "")} ToString() {{ {(generatable.ToStringExpression.Contains('?') ? "// Null-safety protects instances produced by GetUninitializedObject()" : "")} return {generatable.ToStringExpression}; @@ -391,7 +508,7 @@ namespace {containingNamespace} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.ToStringOverride) ? "*/" : "")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GetHashCodeOverride) ? "/*" : "")} - public sealed override int GetHashCode() + public {(generatable.IsClass ? "sealed " : "")} override int GetHashCode() {{ #pragma warning disable RS1024 // Compare symbols correctly {(generatable.HashCodeExpression.Contains('?') ? "// Null-safety protects instances produced by GetUninitializedObject()" : "")} @@ -401,37 +518,91 @@ public sealed override int GetHashCode() {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.GetHashCodeOverride) ? "*/" : "")} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsOverride) ? "/*" : "")} - public sealed override bool Equals(object? other) + 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}? other) + public bool Equals({typeName}{(generatable.IsClass ? "?" : "")} other) {{ - return other is null + return {(!generatable.IsClass ? "" : @"other is null ? false - : {generatable.EqualityExpression}; + : ")}{generatable.EqualityExpression}; }} {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.EqualsMethod) ? " */" : "")} - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) ? "/*" : "")} - {(isComparable ? "" : "/*")} - public int CompareTo({typeName}? other) + {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.CompareToMethod) || !isComparable ? "/*" : "")} + public int CompareTo({typeName}{(generatable.IsClass ? "?" : "")} other) {{ - return other is null + return {(!generatable.IsClass ? "" : @"other is null ? +1 - : {generatable.ComparisonExpression}; + : ")}{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 ? "" : "/*")} + {(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.CompareToMethod) ? "*/" : "")} + + {(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) + {{ + 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() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [return: MaybeNull] + {underlyingTypeFullyQualifiedName} IValueWrapper<{typeName}, {underlyingTypeFullyQualifiedName}>.Serialize() {{ return this.Value; }} @@ -439,144 +610,117 @@ 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 + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = ""{valueFieldName}"")] + private static extern ref {underlyingTypeFullyQualifiedName} GetValueFieldReference({typeName} instance);" : "")} + /// - /// Deserializes a plain value back into a domain object, without any validation. + /// Deserializes a plain value back into a domain object, without using a parameterized constructor. /// - static {typeName} {Constants.SerializableDomainObjectInterfaceTypeName}<{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) -#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) ? "/*" : "")} - 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) ? "*/" : "")} - - {(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) ? "*/" : "")} - {(isComparable ? "" : "*/")} + {(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 => this.Value is {{ }} actual ? ValueWrapperUnwrapper.Unwrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(actual) : default; - {(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) ? "*/" : "")} + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static {typeName} IValueWrapper<{typeName}, {coreTypeFullyQualifiedName}>.Create({coreTypeFullyQualifiedName} value) + {{ + var intermediateValue = ValueWrapperUnwrapper.Wrap<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(value); + return ValueWrapperUnwrapper.Wrap<{typeName}, {underlyingTypeFullyQualifiedName}>(intermediateValue); + }} - {(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) ? "*/" : "")} + /// + /// Serializes a domain object as a plain value. + /// + [return: MaybeNull] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + {coreTypeFullyQualifiedName} IValueWrapper<{typeName}, {coreTypeFullyQualifiedName}>.Serialize() + {{ + var intermediateValue = DomainObjectSerializer.Serialize<{typeName}, {underlyingTypeFullyQualifiedName}>(this); + return DomainObjectSerializer.Serialize<{underlyingTypeFullyQualifiedName}, {coreTypeFullyQualifiedName}>(intermediateValue); + }} - {(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) ? "*/" : "")} + /// + /// 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) ? "*/" : "")} - {(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) ? "*/" : "")} + #endregion #region Formatting & Parsing -#if NET7_0_OR_GREATER +//#if !NET10_0_OR_GREATER // Starting with .NET 10, these operations are provided by default implementations and extension methods - {(existingComponents.HasFlags(WrapperValueObjectTypeComponents.FormattableToStringOverride) ? "/*" : "")} + {(!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) ? "*/" : "")} - -#endif - -#if NET8_0_OR_GREATER - - {(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 +//#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) ? "*/" : "")} }} }} "; @@ -591,59 +735,64 @@ 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, + 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, } - 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); } - 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 UnderlyingTypeHasNullableToString { get => this._bits.GetBit(12); set => this._bits.SetBit(12, 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; } @@ -651,7 +800,10 @@ internal sealed record Generatable : IGeneratable 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; } + + public bool IsStruct => !this.IsClass; + public bool UnderlyingCanBeNull => !this.UnderlyingTypeIsStruct || this.UnderlyingTypeIsNullable; } } 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/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/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs b/DomainModeling.Tests/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs new file mode 100644 index 0000000..a753ca0 --- /dev/null +++ b/DomainModeling.Tests/Analyzers/ValueObjectLiftingOnComparisonOperatorAnalyzerTests.cs @@ -0,0 +1,24 @@ +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", "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 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/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/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/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..bff5b0c 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 @@ -390,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) @@ -403,14 +406,14 @@ 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; } public StringWrapperValueObject(string value, StringComparison stringComparison) { this.Value = value ?? throw new ArgumentNullException(nameof(value)); - this.StringComparison = stringComparison; + this.StringComparison = stringComparison.AsDefined(); } } } 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/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/DomainModeling.Tests.csproj b/DomainModeling.Tests/DomainModeling.Tests.csproj index 12ff044..cd262e9 100644 --- a/DomainModeling.Tests/DomainModeling.Tests.csproj +++ b/DomainModeling.Tests/DomainModeling.Tests.csproj @@ -1,11 +1,12 @@ - net8.0 + net9.0;net8.0 Architect.DomainModeling.Tests Architect.DomainModeling.Tests Enable Enable + 13 False @@ -22,11 +23,11 @@ - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -34,7 +35,9 @@ + + diff --git a/DomainModeling.Tests/DummyBuilderTests.cs b/DomainModeling.Tests/DummyBuilderTests.cs index 735eabb..19aceca 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,14 +85,14 @@ 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); } - [Entity] - public sealed class TestEntity : Entity + [Entity] + public sealed class TestEntity : Entity { public DateTime CreationDateTime { get; } public DateOnly CreationDate { get; } @@ -123,8 +146,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/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 67027c0..43c6ec9 100644 --- a/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs +++ b/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs @@ -1,5 +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; @@ -8,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(); } @@ -25,7 +40,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!); @@ -52,31 +73,84 @@ 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); + 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); + + // 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(); - configurationBuilder.Conventions.Remove(); - configurationBuilder.Conventions.Remove(); + configurationBuilder.Conventions.Remove(typeof(ConstructorBindingConvention)); + configurationBuilder.Conventions.Remove(typeof(RelationshipDiscoveryConvention)); + configurationBuilder.Conventions.Remove(typeof(PropertyDiscoveryConvention)); configurationBuilder.ConfigureDomainModelConventions(domainModel => { - domainModel.ConfigureIdentityConventions(); + domainModel.ConfigureIdentityConventions(new IdentityConfigurationOptions() { CaseSensitiveCollation = "BINARY", IgnoreCaseCollation = "NOCASE", }); domainModel.ConfigureWrapperValueObjectConventions(); domainModel.ConfigureEntityConventions(); domainModel.ConfigureDomainEventConventions(); + + 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 + { + public LazyStringWrapperConverter() + : base( + v => v.Value.Value, + v => new LazyStringWrapper(new Lazy(v))) + { + } + } + protected override void OnModelCreating(ModelBuilder modelBuilder) { // Configure only which entities, properties, and keys exist @@ -90,6 +164,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); @@ -104,11 +182,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } -[DomainEvent] +[TestDomainEvent] 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; @@ -124,21 +202,27 @@ public DomainEventForEF(DomainEventForEFId id, object ignored) this.Id = id; } } -[IdentityValueObject] +[TestIdentity] public readonly partial record struct DomainEventForEFId; -[Entity] -internal sealed class EntityForEF : Entity +[IdentityValueObject] +public partial record struct EntityForEFId +{ + private StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; +} + +[TestEntity] +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; 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."); @@ -146,21 +230,23 @@ 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] +[TestWrapper] internal sealed partial class Wrapper1ForEF { - protected override StringComparison StringComparison => StringComparison.Ordinal; + private StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; /// - /// 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; @@ -174,10 +260,10 @@ public Wrapper1ForEF(string value) } [WrapperValueObject] -internal sealed partial class Wrapper2ForEF +internal sealed partial class Wrapper2ForEF : WrapperValueObject { /// - /// 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; @@ -190,23 +276,62 @@ public Wrapper2ForEF(decimal value) } } -[ValueObject] +[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)); +} + +[TestIdentity] +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()); +} + +[TestValueObject] 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; public Wrapper1ForEF One { get; private init; } public Wrapper2ForEF Two { get; private init; } - - public ValueObjectForEF(Wrapper1ForEF one, Wrapper2ForEF two) + 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) { 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/Enums/DefinedEnumTests.cs b/DomainModeling.Tests/Enums/DefinedEnumTests.cs new file mode 100644 index 0000000..9cd0c74 --- /dev/null +++ b/DomainModeling.Tests/Enums/DefinedEnumTests.cs @@ -0,0 +1,95 @@ +using System.Diagnostics.CodeAnalysis; +using Xunit; + +#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.ExceptionFactoryForUndefinedInput = (type, numericValue, errorState) => new InvalidOperationException($"{type.Name} expects a defined value."); + } + + [Fact] + public void UndefinedValue_Regularly_ShouldReturnExpectedResult() + { + 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 ThrowUndefinedInput_WithNongenericOverload_ShouldThrowConfiguredException() + { + Assert.Throws(() => DefinedEnum.ThrowUndefinedInput(typeof(LazyThreadSafetyMode), numericValue: 999, errorState: null)); + } + + [Fact] + public void ThrowUndefinedInput_WithGenericOverload_ShouldThrowConfiguredException() + { + Assert.Throws(() => DefinedEnum.ThrowUndefinedInput((LazyThreadSafetyMode)999, errorState: null)); + } +} diff --git a/DomainModeling.Tests/Enums/InternalEnumExtensionsTests.cs b/DomainModeling.Tests/Enums/InternalEnumExtensionsTests.cs new file mode 100644 index 0000000..3d17606 --- /dev/null +++ b/DomainModeling.Tests/Enums/InternalEnumExtensionsTests.cs @@ -0,0 +1,71 @@ +using System.Runtime.CompilerServices; +using Architect.DomainModeling.Enums; +using Xunit; + +namespace Architect.DomainModeling.Tests.Enums; + +public class InternalEnumExtensionsTests +{ + 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/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/IdentityTests.cs b/DomainModeling.Tests/IdentityTests.cs index 0fe996b..0e920b3 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 @@ -161,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")] @@ -266,9 +309,6 @@ public void CompareTo_WithIgnoreCaseString_ShouldReturnExpectedResult(string? on } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, "", 0)] - [InlineData("", null, 0)] [InlineData("", "", 0)] [InlineData("", "A", -1)] [InlineData("A", "", +1)] @@ -286,9 +326,6 @@ public void GreaterThan_WithString_ShouldReturnExpectedResult(string? one, strin } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, "", 0)] - [InlineData("", null, 0)] [InlineData("", "", 0)] [InlineData("", "A", -1)] [InlineData("A", "", +1)] @@ -296,7 +333,7 @@ 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) { var left = (StringId)one; var right = (StringId)two; @@ -305,6 +342,32 @@ public void LessThan_WithString_ShouldReturnExpectedResult(string? one, string? Assert.Equal(expectedResult >= 0, left >= right); } + [Theory] + [InlineData(1, 1, 0)] + [InlineData(1, 2, -1)] + [InlineData(2, 1, +1)] + public void GreaterThan_Regularly_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); + } + + [Theory] + [InlineData(1, 1, 0)] + [InlineData(1, 2, -1)] + [InlineData(2, 1, +1)] + public void LessThan_Regularly_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); + } + [Theory] [InlineData(null, null)] [InlineData(0, 0)] @@ -351,6 +414,137 @@ 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)] + 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)] @@ -421,6 +615,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)] @@ -537,7 +766,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 +791,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 +817,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 +887,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`1"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanFormattable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanParsable`1"); + } } // Use a namespace, since our source generators dislike nested types @@ -672,12 +914,20 @@ internal partial record struct DecimalId; [IdentityValueObject] internal partial record struct StringId; + [IdentityValueObject] + internal partial record struct WrapperId; + [IdentityValueObject] internal partial struct IgnoreCaseStringId { internal StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; } + [IdentityValueObject] + internal partial struct NestedStringId + { + } + [IdentityValueObject] internal readonly partial struct FormatAndParseTestingIntId { @@ -694,6 +944,10 @@ public FormatAndParseTestingIntWrapper(int value) this.Value = new IntId(value); } } + [IdentityValueObject] + internal partial struct FormatAndParseTestingUriWrapperId + { + } [IdentityValueObject] internal readonly partial struct JsonTestingIntId @@ -748,21 +1002,18 @@ 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(ValueWrapperJsonConverter))] + [Newtonsoft.Json.JsonConverter(typeof(ValueWrapperNewtonsoftJsonConverter))] 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 + IDirectValueWrapper, + ICoreValueWrapper { public int Value { get; private init; } @@ -796,27 +1047,11 @@ 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); - 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; @@ -828,9 +1063,34 @@ static FullySelfImplementedIdentity ISerializableDomainObject 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 NET7_0_OR_GREATER +//#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); @@ -854,10 +1114,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,41 +1125,9 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid public static FullySelfImplementedIdentity Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => (FullySelfImplementedIdentity)ParsingHelper.Parse(utf8Text, provider); -#endif +//#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/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 8e661ac..e2303a1 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()); } @@ -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] @@ -1002,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 @@ -1081,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) @@ -1091,7 +1042,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; } @@ -1105,12 +1056,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; @@ -1141,11 +1090,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) @@ -1157,7 +1110,7 @@ public DefaultComparingStringValue(string? value) } [ValueObject] - public sealed partial class ImmutableArrayValueObject + public sealed partial class ImmutableArrayValueObject : ValueObject { protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase; @@ -1178,8 +1131,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; } @@ -1191,7 +1142,7 @@ public ArrayValueObject(string?[]? stringValues, int?[] intValues) } [ValueObject] - public sealed partial class CustomCollectionValueObject + public sealed partial record class CustomCollectionValueObject { public CustomCollection? Values { get; set; } @@ -1222,6 +1173,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 a118725..23a92ac 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; @@ -27,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() { @@ -153,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")] @@ -201,14 +250,11 @@ 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] - [InlineData(null, null, 0)] - [InlineData(null, "", -1)] - [InlineData("", null, +1)] [InlineData("", "", 0)] [InlineData("", "A", -1)] [InlineData("A", "", +1)] @@ -216,19 +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); } [Theory] - [InlineData(null, null, 0)] - [InlineData(null, "", -1)] - [InlineData("", null, +1)] [InlineData("", "", 0)] [InlineData("", "A", -1)] [InlineData("A", "", +1)] @@ -236,10 +279,36 @@ 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); + } + + [Theory] + [InlineData(1, 1, 0)] + [InlineData(1, 2, -1)] + [InlineData(2, 1, +1)] + public void GreaterThan_Regularly_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); + } + + [Theory] + [InlineData(1, 1, 0)] + [InlineData(1, 2, -1)] + [InlineData(2, 1, +1)] + public void LessThan_Regularly_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); @@ -291,6 +360,146 @@ 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)] + 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)] @@ -362,6 +571,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)] @@ -371,10 +615,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] @@ -386,10 +630,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); } /// @@ -405,7 +649,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); } /// @@ -421,7 +665,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] @@ -466,7 +710,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] @@ -490,6 +735,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); } @@ -515,6 +761,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); } @@ -537,7 +784,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)); } @@ -559,7 +806,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)); } @@ -581,9 +828,19 @@ 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)); } + + [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`1"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanFormattable"); + Assert.DoesNotContain(interfaces, interf => interf.Name == "IUtf8SpanParsable`1"); + } } // Use a namespace, since our source generators dislike nested types @@ -607,12 +864,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 { } @@ -630,15 +887,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 { } @@ -666,7 +923,7 @@ public CustomCollection(string value) /// Should merely compile. /// [WrapperValueObject] - public sealed partial class StringArrayValue + public partial record struct StringArrayValue { } @@ -678,6 +935,16 @@ public sealed partial class DecimalArrayValue : WrapperValueObject { } + [WrapperValueObject] + public partial record struct NestedIntValue + { + } + + [WrapperValueObject] + public partial record class NestedDecimalValue + { + } + [WrapperValueObject] internal partial class FormatAndParseTestingStringWrapper { @@ -694,6 +961,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 @@ -772,23 +1047,19 @@ 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))] - internal sealed partial class FullySelfImplementedWrapperValueObject - : WrapperValueObject, + [System.Text.Json.Serialization.JsonConverter(typeof(ValueWrapperJsonConverter))] + [Newtonsoft.Json.JsonConverter(typeof(ValueWrapperNewtonsoftJsonConverter))] + internal sealed partial class FullySelfImplementedWrapperValueObject : + IWrapperValueObject, + IEquatable, IComparable, -#if NET7_0_OR_GREATER ISpanFormattable, ISpanParsable, -#endif -#if NET8_0_OR_GREATER IUtf8SpanFormattable, IUtf8SpanParsable, -#endif - ISerializableDomainObject + 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) @@ -796,6 +1067,15 @@ public FullySelfImplementedWrapperValueObject(int value) 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 FullySelfImplementedWrapperValueObject(int? value) + : this(value ?? throw new ArgumentNullException(nameof(value))) + { + } + [Obsolete("This constructor exists for deserialization purposes only.")] private FullySelfImplementedWrapperValueObject() { @@ -828,43 +1108,60 @@ public sealed override string ToString() return this.Value.ToString(); } + 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); + + public static bool operator >(FullySelfImplementedWrapperValueObject? left, FullySelfImplementedWrapperValueObject? right) => left is not null && left.CompareTo(right) > 0; + public static bool operator <(FullySelfImplementedWrapperValueObject? left, FullySelfImplementedWrapperValueObject? right) => left is null ? right is not null : left.CompareTo(right) < 0; + public static bool operator >=(FullySelfImplementedWrapperValueObject? left, FullySelfImplementedWrapperValueObject? right) => !(left < right); + public static bool operator <=(FullySelfImplementedWrapperValueObject? left, FullySelfImplementedWrapperValueObject? right) => !(left > right); + + public static explicit operator FullySelfImplementedWrapperValueObject(int value) => new FullySelfImplementedWrapperValueObject(value); + public static implicit operator int(FullySelfImplementedWrapperValueObject instance) => instance.Value; + + [return: NotNullIfNotNull(nameof(value))] + public static explicit operator FullySelfImplementedWrapperValueObject?(int? value) => value is null ? null : new FullySelfImplementedWrapperValueObject(value.Value); + [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 ISerializableDomainObject.Serialize() + int IValueWrapper.Serialize() { return this.Value; } /// - /// Deserializes a plain value back into a domain object without any validation. + /// 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 return new FullySelfImplementedWrapperValueObject() { Value = value }; #pragma warning restore CS0618 +#pragma warning restore IDE0079 } - 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); - - public static bool operator >(FullySelfImplementedWrapperValueObject? left, FullySelfImplementedWrapperValueObject? right) => left is not null && left.CompareTo(right) > 0; - public static bool operator <(FullySelfImplementedWrapperValueObject? left, FullySelfImplementedWrapperValueObject? right) => left is null ? right is not null : left.CompareTo(right) < 0; - public static bool operator >=(FullySelfImplementedWrapperValueObject? left, FullySelfImplementedWrapperValueObject? right) => !(left < right); - public static bool operator <=(FullySelfImplementedWrapperValueObject? left, FullySelfImplementedWrapperValueObject? right) => !(left > right); - - public static explicit operator FullySelfImplementedWrapperValueObject(int value) => new FullySelfImplementedWrapperValueObject(value); - public static implicit operator int(FullySelfImplementedWrapperValueObject instance) => instance.Value; + // 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); - [return: NotNullIfNotNull(nameof(value))] - public static explicit operator FullySelfImplementedWrapperValueObject?(int? value) => value is null ? null : new FullySelfImplementedWrapperValueObject(value.Value); - [return: NotNullIfNotNull(nameof(instance))] - public static implicit operator int?(FullySelfImplementedWrapperValueObject? instance) => instance?.Value; + #endregion #region Formatting & Parsing -#if NET7_0_OR_GREATER +//#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); @@ -888,10 +1185,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); @@ -903,41 +1196,9 @@ public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provid public static FullySelfImplementedWrapperValueObject Parse(ReadOnlySpan utf8Text, IFormatProvider? provider) => (FullySelfImplementedWrapperValueObject)ParsingHelper.Parse(utf8Text, provider); -#endif +//#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.sln b/DomainModeling.sln index 1655b39..14f94cc 100644 --- a/DomainModeling.sln +++ b/DomainModeling.sln @@ -14,13 +14,17 @@ 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 +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 @@ -43,6 +47,14 @@ 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 + {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 new file mode 100644 index 0000000..e81077c --- /dev/null +++ b/DomainModeling/Architect.DomainModeling.targets @@ -0,0 +1,6 @@ + + + + + + diff --git a/DomainModeling/Attributes/DomainEventAttribute.cs b/DomainModeling/Attributes/DomainEventAttribute.cs index 9b130d4..9722b99 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; /// @@ -11,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 b2b0414..321e909 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; /// @@ -18,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 7df1fce..fbf7e0e 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; /// @@ -5,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 092ef95..29cbdbb 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; /// @@ -12,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/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..f0a7346 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; /// @@ -11,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 4bccf04..50e0750 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; /// @@ -12,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/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/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 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/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/Configuration/IDomainEventConfigurator.cs b/DomainModeling/Configuration/IDomainEventConfigurator.cs index a54a783..9425be2 100644 --- a/DomainModeling/Configuration/IDomainEventConfigurator.cs +++ b/DomainModeling/Configuration/IDomainEventConfigurator.cs @@ -16,6 +16,7 @@ void ConfigureDomainEvent< in Args args) where TDomainEvent : IDomainObject; + [SuppressMessage("Style", "IDE0040:Remove accessibility modifiers", Justification = "We always want explicit accessibility for types")] public readonly struct Args { public readonly bool HasDefaultConstructor { get; init; } diff --git a/DomainModeling/Configuration/IEntityConfigurator.cs b/DomainModeling/Configuration/IEntityConfigurator.cs index ef41295..bf3245a 100644 --- a/DomainModeling/Configuration/IEntityConfigurator.cs +++ b/DomainModeling/Configuration/IEntityConfigurator.cs @@ -16,6 +16,7 @@ void ConfigureEntity< in Args args) where TEntity : IEntity; + [SuppressMessage("Style", "IDE0040:Remove accessibility modifiers", Justification = "We always want explicit accessibility for types")] public readonly struct Args { public bool HasDefaultConstructor { get; init; } diff --git a/DomainModeling/Configuration/IIdentityConfigurator.cs b/DomainModeling/Configuration/IIdentityConfigurator.cs index 152e2ab..2f86b3e 100644 --- a/DomainModeling/Configuration/IIdentityConfigurator.cs +++ b/DomainModeling/Configuration/IIdentityConfigurator.cs @@ -13,11 +13,13 @@ 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; + [SuppressMessage("Style", "IDE0040:Remove accessibility modifiers", Justification = "We always want explicit accessibility for types")] public readonly struct Args { } diff --git a/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs b/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs index c9c34b0..90d5d91 100644 --- a/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs +++ b/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs @@ -13,11 +13,13 @@ 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; + [SuppressMessage("Style", "IDE0040:Remove accessibility modifiers", Justification = "We always want explicit accessibility for types")] public readonly struct Args { } 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/Conversions/DomainObjectSerializer.cs b/DomainModeling/Conversions/DomainObjectSerializer.cs index c89870d..b9d7a2e 100644 --- a/DomainModeling/Conversions/DomainObjectSerializer.cs +++ b/DomainModeling/Conversions/DomainObjectSerializer.cs @@ -1,25 +1,44 @@ -#if NET7_0_OR_GREATER - 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 { - 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 /// /// Deserializes an empty, uninitialized instance of type . /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TModel Deserialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel>() where TModel : IDomainObject { @@ -37,6 +56,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); @@ -70,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 @@ -106,13 +127,14 @@ 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); 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) { @@ -129,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 @@ -164,13 +187,14 @@ 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); 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) { @@ -182,5 +206,3 @@ private static MethodCallExpression CreateSerializeExpressionCore([DynamicallyAc #endregion } - -#endif diff --git a/DomainModeling/Conversions/FormattingExtensions.cs b/DomainModeling/Conversions/FormattingExtensions.cs index 5ca909f..46a4907 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. @@ -22,9 +21,8 @@ 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]; } -#endif } diff --git a/DomainModeling/Conversions/FormattingHelper.cs b/DomainModeling/Conversions/FormattingHelper.cs index e1deabe..81ba1a0 100644 --- a/DomainModeling/Conversions/FormattingHelper.cs +++ b/DomainModeling/Conversions/FormattingHelper.cs @@ -13,10 +13,11 @@ 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 { -#if NET7_0_OR_GREATER - /// /// This overload throws because is unavailable. /// Implement the interface to have overload resolution pick the functional overload. @@ -32,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 - return instance.ToString(format, formatProvider); + // 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)!; } #pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution @@ -54,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 @@ -83,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); @@ -106,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; @@ -117,10 +127,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. @@ -143,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); @@ -166,12 +172,10 @@ 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; } #pragma warning restore IDE0060 // Remove unused parameter - -#endif } 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/ObjectInstantiator.cs b/DomainModeling/Conversions/ObjectInstantiator.cs index 02319bc..eacae18 100644 --- a/DomainModeling/Conversions/ObjectInstantiator.cs +++ b/DomainModeling/Conversions/ObjectInstantiator.cs @@ -25,14 +25,10 @@ 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) { -#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 { @@ -48,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/ParsingHelper.cs b/DomainModeling/Conversions/ParsingHelper.cs index f444f37..fca7240 100644 --- a/DomainModeling/Conversions/ParsingHelper.cs +++ b/DomainModeling/Conversions/ParsingHelper.cs @@ -14,10 +14,11 @@ 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 { -#if NET7_0_OR_GREATER - /// /// This overload throws because is unavailable. /// Implement the interface to have overload resolution pick the functional overload. @@ -98,10 +99,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 +167,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..df15782 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 . /// @@ -20,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 @@ -37,9 +38,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 . /// @@ -48,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) { @@ -69,5 +70,4 @@ public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? var result = T.Parse(chars, provider); return result; } -#endif } diff --git a/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs b/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs new file mode 100644 index 0000000..e4df4d4 --- /dev/null +++ b/DomainModeling/Conversions/ValueWrapperFormattingExtensions.cs @@ -0,0 +1,62 @@ +#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; +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 +{ + 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); + } + } + + 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); + } + } + + 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); + } + } +} + +#endif diff --git a/DomainModeling/Conversions/ValueWrapperJsonConverter.cs b/DomainModeling/Conversions/ValueWrapperJsonConverter.cs new file mode 100644 index 0000000..d2d0aa5 --- /dev/null +++ b/DomainModeling/Conversions/ValueWrapperJsonConverter.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: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, + TValue> + : System.Text.Json.Serialization.JsonConverter + where TWrapper : IValueWrapper +{ + 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: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, + TValue> + : System.Text.Json.Serialization.JsonConverter + where TWrapper : IValueWrapper + 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/Conversions/ValueWrapperNewtonsoftJsonConverter.cs b/DomainModeling/Conversions/ValueWrapperNewtonsoftJsonConverter.cs new file mode 100644 index 0000000..aa89777 --- /dev/null +++ b/DomainModeling/Conversions/ValueWrapperNewtonsoftJsonConverter.cs @@ -0,0 +1,99 @@ +using System.Diagnostics.CodeAnalysis; +using System.Numerics; + +namespace Architect.DomainModeling.Conversions; + +/// +/// A generic Newtonsoft JSON converter for wrapper types, which serializes like the wrapped value itself. +/// +public sealed class ValueWrapperNewtonsoftJsonConverter< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, + TValue> + : Newtonsoft.Json.JsonConverter + where TWrapper : IValueWrapper +{ + 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 LargeNumberValueWrapperNewtonsoftJsonConverter< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper, + TValue> + : Newtonsoft.Json.JsonConverter + where TWrapper : IValueWrapper + 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/ValueWrapperParsingExtensions.cs b/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs new file mode 100644 index 0000000..0d24763 --- /dev/null +++ b/DomainModeling/Conversions/ValueWrapperParsingExtensions.cs @@ -0,0 +1,77 @@ +#if NET10_0_OR_GREATER + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Architect.DomainModeling; +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 +{ + 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); + } + } + + 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); + } + } + + 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); + } + } +} + +#endif diff --git a/DomainModeling/Conversions/ValueWrapperUnwrapper.cs b/DomainModeling/Conversions/ValueWrapperUnwrapper.cs new file mode 100644 index 0000000..d29ebf5 --- /dev/null +++ b/DomainModeling/Conversions/ValueWrapperUnwrapper.cs @@ -0,0 +1,69 @@ +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)] + 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 : struct, IValueWrapper + where TValue : struct + { + return value is { } actual + ? TWrapper.Create(actual) + : default; + } + + #endregion + + #region Unwrap + + /// + /// Unwraps the from a . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TValue? Unwrap(TWrapper instance) + where TWrapper : IValueWrapper + { + 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 ebf8110..e11c7b4 100644 --- a/DomainModeling/DomainModeling.csproj +++ b/DomainModeling/DomainModeling.csproj @@ -1,15 +1,22 @@ - + - net8.0;net7.0;net6.0 + net9.0;net8.0 False Architect.DomainModeling Architect.DomainModeling + True Enable Enable - 11 + 13 True True + README.md + + + + + IDE0290 @@ -17,7 +24,7 @@ - 3.0.3 + 4.0.0 A complete Domain-Driven Design (DDD) toolset for implementing domain models, including base types and source generators. @@ -25,40 +32,53 @@ https://github.com/TheArchitectDev/Architect.DomainModeling Release notes: -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. +4.0.0: + +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`. +- 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. +- 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 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: +- 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. +- 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. +- Feature: Analyzer warns when '>' or similar operator risks unintended null handling. +- 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. The Architect The Architect @@ -70,10 +90,8 @@ Release notes: - - True - - + + @@ -81,17 +99,33 @@ Release notes: - - false - Content - PreserveNewest - + + + + + + + + + + + + + + + + - - - + + + + + + + + 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/DomainModeling/Enums/DefinedEnum.cs b/DomainModeling/Enums/DefinedEnum.cs new file mode 100644 index 0000000..85bb940 --- /dev/null +++ b/DomainModeling/Enums/DefinedEnum.cs @@ -0,0 +1,77 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Architect.DomainModeling.Enums; + +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace Architect.DomainModeling; + +/// +/// 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 or is called on an undefined value. + /// + /// + /// Receives the enum type, the numeric value, and the optional error state passed during the validation. + /// + /// + public static Func? ExceptionFactoryForUndefinedInput { get; set; } + + /// + /// + /// Throws the configured exception for enum value of type being undefined for its type. + /// + /// + /// This method can be used to produce the same effect as without calling it, such as from a mapper. + /// + /// + /// 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) + { + throw ExceptionFactoryForUndefinedInput?.Invoke(enumType, numericValue, errorState) ?? new ArgumentException($"Only recognized {enumType.Name} values are permitted."); + } + + /// + /// + /// Throws the configured exception for enum value being undefined for its type. + /// + /// + /// This method can be used to produce the same effect as without calling it, such as from a mapper. + /// + /// + /// The enum's value. + /// An optional error state to be passed to . + [DoesNotReturn] + public static void ThrowUndefinedInput(TEnum value, string? errorState = null) + where TEnum : unmanaged, Enum + { + ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue(), errorState); + } + + /// + /// + /// Contains an undefined value for . + /// + /// + /// 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. + /// + /// + public static class UndefinedValues + where TEnum : unmanaged, Enum + { + 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()); + + 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)."); + } +} diff --git a/DomainModeling/Enums/EnumExtensions.cs b/DomainModeling/Enums/EnumExtensions.cs new file mode 100644 index 0000000..270943b --- /dev/null +++ b/DomainModeling/Enums/EnumExtensions.cs @@ -0,0 +1,90 @@ +using Architect.DomainModeling.Enums; + +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace Architect.DomainModeling; + +/// +/// Provides enum extensions for domain modeling. +/// +public static class EnumExtensions +{ + /// + /// + /// Validates that is a defined value, throwing otherwise. + /// + /// + /// The potential exception can be globally configured using . + /// + /// + public static TEnum AsDefined(this TEnum value, string? errorState = null) + where TEnum : unmanaged, Enum + { + if (!Enum.IsDefined(value)) + DefinedEnum.ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue(), errorState); + + return value; + } + + /// + /// + /// Validates that is either null or a defined value, throwing otherwise. + /// + /// + /// The potential exception can be globally configured using . + /// + /// + public static TEnum? AsDefined(this TEnum? value, string? errorState = null) + where TEnum : unmanaged, Enum + { + return value is TEnum actual ? AsDefined(actual, errorState) : null; + } + + /// + /// + /// Validates that is a valid combination of bits used in the defined values for , throwing otherwise. + /// + /// + /// The potential exception can be globally configured using . + /// + /// + public static TEnum AsDefinedFlags(this TEnum value, string? errorState = null) + where TEnum : unmanaged, Enum + { + if ((DefinedEnum.UndefinedValues.AllFlags | value.GetBinaryValue()) != DefinedEnum.UndefinedValues.AllFlags) + DefinedEnum.ThrowUndefinedInput(typeof(TEnum), value.GetNumericValue(), errorState); + + return value; + } + + /// + /// + /// Validates that is either null or a valid combination of bits used in the defined values for , throwing otherwise. + /// + /// + /// The potential exception can be globally configured using . + /// + /// + public static TEnum? AsDefinedFlags(this TEnum? value, string? errorState = null) + where TEnum : unmanaged, Enum + { + return value is TEnum actual ? AsDefinedFlags(actual, errorState) : null; + } + + /// + /// 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; + } + + /// + /// 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/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/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/ISerializableDomainObject.cs b/DomainModeling/ISerializableDomainObject.cs index 4e96be4..4836369 100644 --- a/DomainModeling/ISerializableDomainObject.cs +++ b/DomainModeling/ISerializableDomainObject.cs @@ -3,8 +3,9 @@ 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 . /// +[Obsolete("Use IValueWrapper instead.", error: true)] public interface ISerializableDomainObject< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying> @@ -14,10 +15,8 @@ public interface ISerializableDomainObject< /// TUnderlying? Serialize(); -#if NET7_0_OR_GREATER /// /// Deserializes a from a . /// abstract static TModel Deserialize(TUnderlying value); -#endif } 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/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 +{ +} 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 { } 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); + } +} diff --git a/README.md b/README.md index 5c9be0c..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 @@ -23,10 +24,15 @@ 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 { + [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 : ValueObject } ``` -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. @@ -46,20 +55,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 +90,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; @@ -88,131 +100,217 @@ public class Description : WrapperValueObject { 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?`). -- 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. -### Entity +#### Structs -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. +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. -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. +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. -Consider the following type: +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 + +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 -[Entity] -public class Payment : Entity +[ValueObject] +public partial record class Address { - public string Currency { get; } - public decimal Amount { get; } - - public Payment(string currency, decimal amount) - : base(new PaymentId()) + [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( + ProperName streetAndNumber, + ProperName city, + ZipCode zipCode, + Kind kind) { - this.Currency = currency ?? throw new ArgumentNullException(nameof(currency)); - this.Amount = amount; + 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 } } ``` -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. +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`. -Change the type as follows to get a source-generated ID type for the entity: +The exception can be fully customized: ```cs -[Entity] -public class Payment : Entity +public class Program { - // Snip + [ModuleInitializer] + internal static void Initialize() + { + DefinedEnum.ExceptionFactoryForUndefinedInput = (Type type, Int128 value, string? state) => + throw new ValidationException(HttpStatusCode.BadRequest, errorCode: state ?? "OptionInvalid", message: $"Only recognized {type.Name} values are permitted."); + } } ``` -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). +The following extension methods are available: -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`'. +```cs +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 +``` -Furthermore, the above example entity could be modified to create a new, unique ID on construction: +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 Payment(string currency, decimal amount) - : base(new PaymentId(Guid.NewGuid().ToString("N"))) +public static AddressKind ToDomain(AddressKindDto dto) { - // Snip + return dto switch + { + 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 + }); } ``` -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 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 +[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. + +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 Currency Currency { get; private set; } // Struct WrapperValueObject :) + public decimal Amount { get; private set; } + + public Payment( + Currency currency, + decimal amount) + : base(new PaymentId()) + { + this.Currency = currency; + this.Amount = amount; + } } ``` -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: +The entity needs a `PaymentId` type, a lightweight, more expressive wrapper around something like `Guid`. We could have it generated with a oneliner: ```cs -[Identity] -public partial record struct ExternalId; +[IdentityValueObject] public partial record struct PaymentId; ``` -Note that an [entity](#entity) has the option of having its own ID type generated implicitly, with practically no code at all. +We can even avoid that extra type declaration we would otherwise need to put somewhere: + +```cs +[Entity] +public class Payment : Entity +{ + // Snip +} +``` + +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( + Currency currency, + decimal amount) + : base(new PaymentId(Guid.CreateVersion7())) +{ + // Snip +} +``` + +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, 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 @@ -227,7 +325,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 +353,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 @@ -273,12 +371,11 @@ 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 [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 +391,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: @@ -305,36 +404,37 @@ 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. 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. @@ -358,7 +458,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 @@ -366,11 +466,15 @@ 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 { // Snip + [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) { // Recommended to keep EF from throwing if it sees no usable constructor, if we are keeping it from using constructors anyway @@ -378,20 +482,47 @@ internal sealed class MyDbContext : DbContext configurationBuilder.ConfigureDomainModelConventions(domainModel => { + // Defaults domainModel.ConfigureIdentityConventions(); domainModel.ConfigureWrapperValueObjectConventions(); domainModel.ConfigureEntityConventions(); domainModel.ConfigureDomainEventConventions(); + + // Customizations + 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"); + } + }); + + // Customizations + 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); + } + }); }); } } ``` -`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. -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. @@ -439,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. @@ -455,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 @@ -473,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();