Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6b8ae0d
.NET 8+ only, IIdentity implements IWrapperValueObject, bug fixes, tr…
Timovzl Jul 27, 2025
5def944
Generic JSON serializers instead of generated.
Timovzl Jul 27, 2025
2dbb800
Removed #if NET7/8 conditionals, now that 8 is the minimum version.
Timovzl Jul 27, 2025
898c491
Upgraded LangVersion and handled compiler suggestions.
Timovzl Jul 27, 2025
b53bf34
Suppressions and summary corrections.
Timovzl Sep 3, 2025
a60110f
Implemented formatting/parsing via default interface implementations …
Timovzl Sep 3, 2025
7499784
Removed outcommented code.
Timovzl Sep 3, 2025
af70895
Generator performance and cleanup.
Timovzl Sep 3, 2025
6e09698
Added EnumerableComparer overloads that avoid boxing for ImmutableAra…
Timovzl Sep 3, 2025
6bfe159
Added serialization to/from deepest underlying type (recursive).
Timovzl Sep 5, 2025
d835834
Added wrapper EF collations, collation checks, and provider comparers.
Timovzl Sep 8, 2025
eda50d7
Shortened release notes
Timovzl Sep 9, 2025
4fa0364
Added [CompilerGenerated] to all generated types.
Timovzl Sep 10, 2025
376e59a
Added Analyzer project, and analyzer for inadvertent comparisons base…
Timovzl Sep 10, 2025
ccff90a
Moved Equals() and Compare() helpers from generated into helper class.
Timovzl Sep 15, 2025
904ce4f
Generated [Wrapper]ValueObjects can now be [structs and/or] records a…
Timovzl Sep 15, 2025
facbff8
Nicer DebuggerDisplay of Wrappers/Identities.
Timovzl Sep 17, 2025
9f0734f
Made Entity.Id private init.
Timovzl Sep 17, 2025
bdfe72f
DummyBuilder records clone themselves on each step, for reuse.
Timovzl Sep 24, 2025
b6cee82
DefinedEnum<TEnum> for validated enums.
Timovzl Sep 24, 2025
a671496
Revert "Made Entity.Id private init."
Timovzl Sep 24, 2025
8f56fc8
Replaced DefinedEnums by enum validation.
Timovzl Sep 30, 2025
9f478b0
Entity == operator, entity ID generation type param moved from base t…
Timovzl Oct 8, 2025
c70e9dd
- Generated default ctor and deserialization now accounts for situati…
Timovzl Nov 13, 2025
57b82c3
Added .NET 10 and fixed an issue with the new parsing/formatting exte…
Timovzl Nov 21, 2025
9774c39
ValueObject comparison operators disallow null.
Timovzl Nov 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions DomainModeling.Analyzer/AnalyzerTypeSymbolExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Encourages migrating from Entity&lt;TId, TPrimitive&gt; to EntityAttribute&lt;TId, TIdUnderlying&gt;.
/// </summary>
[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<TId, TIdPrimitive> is deprecated in favor of the [Entity<TId, TIdUnderlying>] attribute. Use the extended attribute and remove TIdPrimitive from the base class.",
category: "Design",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);

public override ImmutableArray<DiagnosticDescriptor> 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<TId, TIdPrimitive>
if (baseTypeSymbol is null)
return;

var diagnostic = Diagnostic.Create(DiagnosticDescriptor, baseType.GetLocation());
context.ReportDiagnostic(diagnostic);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

namespace Architect.DomainModeling.Analyzer.Analyzers;

/// <summary>
/// Prevents assignment of unvalidated enum values to members of an IDomainObject.
/// </summary>
[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<DiagnosticDescriptor> 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<IOperation> 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,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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;

/// <summary>
/// In [Wrapper]ValueObjects, this analyzer warns against the use of property/field initializers in classes without a default constructor (such as when a primary constructor is used).
/// Without a default constructor, deserialization uses GetUninitializedObject(), which skips property/field initializers, likely to the developer's surprise.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ValueObjectFieldInitializerWithoutDefaultCtorAnalyzer : 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: "ValueObjectFieldInitializerWithoutDefaultConstructor",
title: "ValueObject has field initializers but no default constructor",
messageFormat: "Field initializer on value object with no default constructor. Lack of a default constructor forces deserialization to use GetUninitializedObject(), which skips field initializers. Consider a calculated property (=> syntax) to avoid field initializers.",
category: "Design",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [DiagnosticDescriptor];

public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze);

context.RegisterSyntaxNodeAction(AnalyzeFieldDeclaration, SyntaxKind.FieldDeclaration);
context.RegisterSyntaxNodeAction(AnalyzePropertyDeclaration, SyntaxKind.PropertyDeclaration);
}

private static void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context)
{
var field = (FieldDeclarationSyntax)context.Node;
var semanticModel = context.SemanticModel;

if (!IsMemberOfRelevantType(field, semanticModel, context.CancellationToken))
return;

// A field declaration can actually define multiple fields at once: private int One, Two = 1, 2;
foreach (var fieldVariable in field.Declaration.Variables)
{
if (fieldVariable.Initializer?.Value is not { } initializer)
continue;

// When using a primary constructor, then we use field initializers to assign its parameters
// Such initializers are fine
// It is only OTHER (non-parameterized) initalizers that are likely to cause confusion
if (InitializerReferencesAnyConstructorParameter(initializer, semanticModel, context.CancellationToken))
return;

context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptor, fieldVariable.Initializer.GetLocation()));
}
}

private static void AnalyzePropertyDeclaration(SyntaxNodeAnalysisContext context)
{
var property = (PropertyDeclarationSyntax)context.Node;
var semanticModel = context.SemanticModel;

if (!IsMemberOfRelevantType(property, semanticModel, context.CancellationToken))
return;

if (property.Initializer?.Value is not { } initializer)
return;

// When using a primary constructor, then we use field initializers to assign its parameters
// Such initializers are fine
// It is only OTHER (non-parameterized) initalizers that are likely to cause confusion
if (InitializerReferencesAnyConstructorParameter(initializer, semanticModel, context.CancellationToken))
return;

context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptor, property.Initializer.GetLocation()));
}

private static bool IsMemberOfRelevantType(MemberDeclarationSyntax member, SemanticModel semanticModel, CancellationToken cancellationToken)
{
if (member.Parent is not TypeDeclarationSyntax tds)
return false;

if (semanticModel.GetDeclaredSymbol(tds, cancellationToken) is not { } type)
return false;

// Only in reference types with the [Wrapper]ValueObjectAttributes
if (type.IsValueType || !type.GetAttributes().Any(attr => attr.AttributeClass is
{
Name: "ValueObjectAttribute" or "WrapperValueObjectAttribute",
ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } },
}))
return false;

// Only without a default ctor
if (type.InstanceConstructors.Any(ctor => ctor.Parameters.Length == 0))
return false;

return true;
}

private static bool InitializerReferencesAnyConstructorParameter(ExpressionSyntax initializer, SemanticModel semanticModel, CancellationToken cancellationToken)
{
// The initializer might reference constructor parameters
// However, it might also DECLARE parameters and then reference them, which is irrelevant to us
// To see the distinction, first observe which parameters are declared INSIDE the initializer
var parametersDeclaredInsideInitializer = new HashSet<IParameterSymbol>(SymbolEqualityComparer.Default);
foreach (var parameterSyntax in initializer.DescendantNodesAndSelf().OfType<ParameterSyntax>())
if (semanticModel.GetDeclaredSymbol(parameterSyntax, cancellationToken) is { } parameterSymbol)
parametersDeclaredInsideInitializer.Add(parameterSymbol);

foreach (var id in initializer.DescendantNodesAndSelf().OfType<IdentifierNameSyntax>())
{
var symbol = semanticModel.GetSymbolInfo(id, cancellationToken).Symbol;
if (symbol is IParameterSymbol parameterSymbol && !parametersDeclaredInsideInitializer.Contains(parameterSymbol))
{
// Parameter originates outside initializer
// This initializer uses primary ctor params
return true;
}
}
return false;
}
}
Loading