Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
98ca4e9
Added wrapper EF collations, collation checks, and provider comparers.
Timovzl Sep 8, 2025
6c514de
Shortened release notes
Timovzl Sep 9, 2025
d8c08d6
Added [CompilerGenerated] to all generated types.
Timovzl Sep 10, 2025
b73cd7c
Added Analyzer project, and analyzer for inadvertent comparisons base…
Timovzl Sep 10, 2025
f2040dd
Moved Equals() and Compare() helpers from generated into helper class.
Timovzl Sep 15, 2025
2b5e7ba
Generated [Wrapper]ValueObjects can now be [structs and/or] records a…
Timovzl Sep 15, 2025
a3b4a86
Nicer DebuggerDisplay of Wrappers/Identities.
Timovzl Sep 17, 2025
a68f1c6
Made Entity.Id private init.
Timovzl Sep 17, 2025
a6c0392
DummyBuilder records clone themselves on each step, for reuse.
Timovzl Sep 24, 2025
7936457
DefinedEnum<TEnum> for validated enums.
Timovzl Sep 24, 2025
3b46673
Revert "Made Entity.Id private init."
Timovzl Sep 24, 2025
b50f618
Replaced DefinedEnums by enum validation.
Timovzl Sep 30, 2025
402bead
Entity == operator, entity ID generation type param moved from base t…
Timovzl Oct 8, 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,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;

/// <summary>
/// Prevents accidental equality/comparison operator usage between unrelated types, where implicit conversions inadvertently make the operation compile.
/// </summary>
[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<DiagnosticDescriptor> 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;
}
}
Loading