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
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