Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
a89ee9e
Also added IValueWrapper<TValue> interfaces (without TWrapper), which…
Timovzl Mar 22, 2026
ca7de32
Fixed suggestions by new .NET analyzers.
Timovzl Mar 22, 2026
6a0b37f
Fixed a bug in ValueObjectGenerator where the ToString() override was…
Timovzl Mar 22, 2026
cd3e7eb
Reduced enum sizes in generators.
Timovzl Mar 22, 2026
aaf25f0
Improved dummy builders with interfaces and better copyable model typ…
Timovzl Mar 22, 2026
2078e67
Made csproj LangVersions consistent and suppressed a needless suggest…
Timovzl Mar 22, 2026
c98eb62
Fixed bugs in InternalEnumExtensions.
Timovzl Mar 22, 2026
149aaff
Fixed some generator type conversion safety bugs, and upped package v…
Timovzl Mar 22, 2026
b5162fa
Enabled struct ValueObjects.
Timovzl Mar 22, 2026
103ef01
Improved wrapper check for existing parameterized ctor.
Timovzl Mar 27, 2026
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,63 @@
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 the use of default expressions and literals on struct ValueObject and WrapperValueObject types, so that validation cannot be circumvented.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ValueObjectDefaultExpressionAnalyzer : 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: "ValueObjectDefaultExpression",
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<DiagnosticDescriptor> 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<TValue>
if (!type.GetAttributes().Any(attr => attr.AttributeClass is
{ Arity: 0, Name: "ValueObjectAttribute", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } }, } or
{ 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);
}
}
Loading