Skip to content

Commit 416c79b

Browse files
committed
Entity == operator, entity ID generation type param moved from base to attribute, and attribute inheritance.
1 parent 5998a57 commit 416c79b

34 files changed

+923
-315
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
7+
namespace Architect.DomainModeling.Analyzer.Analyzers;
8+
9+
/// <summary>
10+
/// Encourages migrating from Entity&lt;TId, TPrimitive&gt; to EntityAttribute&lt;TId, TIdUnderlying&gt;.
11+
/// </summary>
12+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
13+
public sealed class EntityBaseClassWithIdTypeGenerationAnalyzer : DiagnosticAnalyzer
14+
{
15+
public const string DiagnosticId = "EntityBaseClassWithIdTypeGeneration";
16+
17+
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")]
18+
[System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")]
19+
private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor(
20+
id: "EntityBaseClassWithIdTypeGeneration",
21+
title: "Used entity base class instead of attribute to initiate ID type source generation",
22+
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.",
23+
category: "Design",
24+
defaultSeverity: DiagnosticSeverity.Warning,
25+
isEnabledByDefault: true);
26+
27+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [DiagnosticDescriptor];
28+
29+
public override void Initialize(AnalysisContext context)
30+
{
31+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
32+
context.EnableConcurrentExecution();
33+
34+
context.RegisterSyntaxNodeAction(AnalyzeClassDeclaration, SyntaxKind.ClassDeclaration);
35+
}
36+
37+
private static void AnalyzeClassDeclaration(SyntaxNodeAnalysisContext context)
38+
{
39+
var classDeclaration = (ClassDeclarationSyntax)context.Node;
40+
41+
// Get the first base type (the actual base class rather than interfaces)
42+
if (classDeclaration.BaseList is not { Types: { Count: > 0 } baseTypes } || baseTypes[0] is not { } baseType)
43+
return;
44+
45+
var typeInfo = context.SemanticModel.GetTypeInfo(baseType.Type, context.CancellationToken);
46+
if (typeInfo.Type is not INamedTypeSymbol baseTypeSymbol)
47+
return;
48+
49+
while (baseTypeSymbol is not null)
50+
{
51+
if (baseTypeSymbol is { Arity: 2, Name: "Entity", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } } })
52+
break;
53+
54+
baseTypeSymbol = baseTypeSymbol.BaseType!;
55+
}
56+
57+
// If Entity<TId, TIdPrimitive>
58+
if (baseTypeSymbol is null)
59+
return;
60+
61+
var diagnostic = Diagnostic.Create(DiagnosticDescriptor, baseType.GetLocation());
62+
context.ReportDiagnostic(diagnostic);
63+
}
64+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CodeActions;
5+
using Microsoft.CodeAnalysis.CodeFixes;
6+
using Microsoft.CodeAnalysis.CSharp;
7+
using Microsoft.CodeAnalysis.CSharp.Syntax;
8+
9+
namespace Architect.DomainModeling.CodeFixProviders;
10+
11+
/// <summary>
12+
/// Provides a code fix for migrating from Entity&lt;TId, TPrimitive&gt; to EntityAttribute&lt;TId, TIdUnderlying&gt;.
13+
/// </summary>
14+
[Shared]
15+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(EntityBaseClassWithIdTypeGenerationCodeFixProvider))]
16+
public sealed class EntityBaseClassWithIdTypeGenerationCodeFixProvider : CodeFixProvider
17+
{
18+
private static readonly ImmutableArray<string> FixableDiagnosticIdConstant = ["EntityBaseClassWithIdTypeGeneration"];
19+
20+
public sealed override ImmutableArray<string> FixableDiagnosticIds => FixableDiagnosticIdConstant;
21+
22+
public sealed override FixAllProvider GetFixAllProvider()
23+
{
24+
return WellKnownFixAllProviders.BatchFixer;
25+
}
26+
27+
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
28+
{
29+
var diagnostic = context.Diagnostics.First(diagnostic => diagnostic.Id == FixableDiagnosticIdConstant[0]);
30+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
31+
if (root is null)
32+
return;
33+
34+
if (root.FindNode(diagnostic.Location.SourceSpan) is not BaseTypeSyntax baseTypeSyntax)
35+
return;
36+
37+
var tds = baseTypeSyntax.Ancestors().OfType<TypeDeclarationSyntax>().FirstOrDefault();
38+
if (tds is null)
39+
return;
40+
41+
// Do not offer the fix for abstract types
42+
if (tds.Modifiers.Any(SyntaxKind.AbstractKeyword))
43+
return;
44+
45+
// Do not offer the fix if the inheritance is indirect
46+
if (baseTypeSyntax.Type is not GenericNameSyntax { Arity: 2, TypeArgumentList.Arguments.Count: 2, } entityBaseTypeSyntax)
47+
return;
48+
var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
49+
if (semanticModel.GetTypeInfo(baseTypeSyntax.Type, context.CancellationToken).Type is not
50+
INamedTypeSymbol { Arity: 2, Name: "Entity", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true, } } } entityBaseType)
51+
return;
52+
53+
var action = CodeAction.Create(
54+
title: "Move type parameter for ID's underlying type into EntityAttribute",
55+
createChangedDocument: ct => ConvertToAttributeAsync(context.Document, root, tds, entityBaseTypeSyntax),
56+
equivalenceKey: "MoveIdTypeGenerationFromBaseToAttribute");
57+
context.RegisterCodeFix(action, diagnostic);
58+
}
59+
60+
private static Task<Document> ConvertToAttributeAsync(
61+
Document document,
62+
SyntaxNode root,
63+
TypeDeclarationSyntax tds,
64+
GenericNameSyntax baseTypeSyntax)
65+
{
66+
var idTypeToGenerate = baseTypeSyntax.TypeArgumentList.Arguments[0];
67+
var idUnderlyingType = baseTypeSyntax.TypeArgumentList.Arguments[1];
68+
69+
// Create Entity<TId, TIdUnderlying> attribute
70+
var attributeArguments = SyntaxFactory.SeparatedList([idTypeToGenerate, idUnderlyingType,]);
71+
var entityAttribute =
72+
SyntaxFactory.Attribute(
73+
SyntaxFactory.GenericName(SyntaxFactory.Identifier("Entity"))
74+
.WithTypeArgumentList(SyntaxFactory.TypeArgumentList(attributeArguments)));
75+
76+
// Check if Entity attribute already exists
77+
var existingEntityAttribute = tds.AttributeLists
78+
.SelectMany(list => list.Attributes)
79+
.FirstOrDefault(attribute => attribute.Name is IdentifierNameSyntax { Identifier.Text: "Entity" or "EntityAttribute" } or QualifiedNameSyntax { Right.Identifier.Text: "Entity" or "EntityAttribute" });
80+
81+
// Replace or add the Entity attribute
82+
TypeDeclarationSyntax newTds;
83+
if (existingEntityAttribute is { Parent: AttributeListSyntax oldAttributeList })
84+
{
85+
var newAttributes = oldAttributeList.Attributes.Replace(existingEntityAttribute, entityAttribute);
86+
var newAttributeList = oldAttributeList
87+
.WithAttributes(newAttributes)
88+
.WithLeadingTrivia(oldAttributeList.GetLeadingTrivia())
89+
.WithTrailingTrivia(oldAttributeList.GetTrailingTrivia());
90+
newTds = tds.ReplaceNode(oldAttributeList, newAttributeList);
91+
}
92+
else
93+
{
94+
// This requires some gymnastics to keep the trivia intact
95+
96+
// No existing attributes - move the type's leading trivia onto the new attribute
97+
if (tds.AttributeLists.Count == 0)
98+
{
99+
var attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(entityAttribute))
100+
.WithLeadingTrivia(tds.GetLeadingTrivia());
101+
var newAttributeLists = tds.AttributeLists.Add(attributeList);
102+
newTds = tds
103+
.WithoutLeadingTrivia()
104+
.WithAttributeLists(newAttributeLists);
105+
}
106+
// Existing attributes - carefully preserve keep the leading trivia on the first attribute
107+
else
108+
{
109+
var attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(entityAttribute));
110+
var newAttributeLists = tds.AttributeLists
111+
.Replace(tds.AttributeLists[0], tds.AttributeLists[0].WithLeadingTrivia(tds.AttributeLists[0].GetLeadingTrivia()))
112+
.Add(attributeList);
113+
newTds = tds.WithAttributeLists(newAttributeLists);
114+
}
115+
}
116+
117+
// Replace the base type
118+
if (newTds.BaseList is { Types: { Count: > 0 } baseTypes } && baseTypes[0].Type is GenericNameSyntax { Arity: 2, TypeArgumentList.Arguments: { Count: 2 } typeArgs, } genericBaseType)
119+
{
120+
var newTypeArgs = SyntaxFactory.TypeArgumentList(
121+
SyntaxFactory.SingletonSeparatedList(typeArgs[0]));
122+
var newGenericBaseType = genericBaseType.WithTypeArgumentList(newTypeArgs);
123+
var newBaseType = baseTypes[0]
124+
.WithType(newGenericBaseType)
125+
.WithLeadingTrivia(baseTypes[0].GetLeadingTrivia())
126+
.WithTrailingTrivia(baseTypes[0].GetTrailingTrivia());
127+
newTds = newTds.ReplaceNode(baseTypes[0], newBaseType);
128+
}
129+
130+
var newRoot = root.ReplaceNode(tds, newTds);
131+
return Task.FromResult(document.WithSyntaxRoot(newRoot));
132+
}
133+
}

DomainModeling.CodeFixProviders/MissingStringComparisonCodeFixProvider.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
namespace Architect.DomainModeling.CodeFixProviders;
1010

11+
/// <summary>
12+
/// Provides code fixes to add a missing StringComparison property to [Wrapper]ValueObjects with string members.
13+
/// </summary>
1114
[Shared]
1215
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MissingStringComparisonCodeFixProvider))]
1316
public sealed class MissingStringComparisonCodeFixProvider : CodeFixProvider
@@ -25,15 +28,13 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
2528
{
2629
var diagnostic = context.Diagnostics.First(diagnostic => diagnostic.Id == FixableDiagnosticIdConstant[0] || diagnostic.Id == FixableDiagnosticIdConstant[1]);
2730
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
28-
2931
if (root is null)
3032
return;
3133

3234
var token = root.FindToken(diagnostic.Location.SourceSpan.Start);
3335
var tds = token.Parent?.AncestorsAndSelf()
3436
.OfType<TypeDeclarationSyntax>()
3537
.FirstOrDefault();
36-
3738
if (tds is null)
3839
return;
3940

DomainModeling.CodeFixProviders/UnvalidatedEnumMemberAssignmentCodeFixer.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
namespace Architect.DomainModeling.CodeFixProviders;
1111

12+
/// <summary>
13+
/// Provides code fixes for unvalid assignments of enum values to domain object members.
14+
/// </summary>
1215
[Shared]
1316
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UnvalidatedEnumMemberAssignmentCodeFixer))]
1417
public sealed class UnvalidatedEnumMemberAssignmentCodeFixer : CodeFixProvider
@@ -26,7 +29,8 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
2629
{
2730
var diagnostic = context.Diagnostics.First(diagnostic => diagnostic.Id == FixableDiagnosticIdConstant[0]);
2831
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
29-
if (root is null) return;
32+
if (root is null)
33+
return;
3034

3135
var node = root.FindNode(diagnostic.Location.SourceSpan);
3236
if (node is not ExpressionSyntax unvalidatedValue)
Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,29 @@
1-
using Architect.DomainModeling.Comparisons;
2-
3-
namespace Architect.DomainModeling.Example;
4-
5-
// Use "Go To Definition" on the type to view the source-generated partial
6-
// Uncomment the IComparable interface to see how the generated code changes
7-
[WrapperValueObject<string>]
8-
public partial record struct Description //: IComparable<Description>
9-
{
10-
// For string wrappers, we must define how they are compared
11-
private StringComparison StringComparison => StringComparison.OrdinalIgnoreCase;
12-
13-
// Any component that we define manually is omitted by the generated code
14-
// For example, we can explicitly define the Value property to have greater clarity, since it is quintessential
15-
public string Value { get; private init; }
16-
17-
// An explicitly defined constructor allows us to enforce the domain rules and invariants
18-
public Description(string value)
19-
{
20-
this.Value = value ?? throw new ArgumentNullException(nameof(value));
21-
22-
if (this.Value.Length > 255) throw new ArgumentException("Too long.");
23-
24-
if (ValueObjectStringValidator.ContainsNonWordCharacters(this.Value)) throw new ArgumentException("Nonsense.");
25-
}
26-
}
1+
using Architect.DomainModeling.Comparisons;
2+
3+
namespace Architect.DomainModeling.Example;
4+
5+
// Use "Go To Definition" on the type to view the source-generated partial
6+
// Outcomment the IComparable interface to see how the generated code changes
7+
[WrapperValueObject<string>]
8+
public partial record struct Currency : IComparable<Currency>
9+
{
10+
// For string wrappers, we must define how they are compared
11+
private StringComparison StringComparison => StringComparison.OrdinalIgnoreCase;
12+
13+
// Any component that we define manually is omitted by the generated code
14+
// For example, we can explicitly define the Value property to have greater clarity, since it is quintessential
15+
public string Value { get; private init; }
16+
17+
// An explicitly defined constructor allows us to enforce the domain rules and invariants
18+
public Currency(string value)
19+
{
20+
// Note: We could even choose to do ToUpperInvariant() on the input value, for a more consistent internal representation
21+
this.Value = value ?? throw new ArgumentNullException(nameof(value));
22+
23+
if (this.Value.Length != 3)
24+
throw new ArgumentException($"A {nameof(Currency)} must be exactly 3 chars long.");
25+
26+
if (ValueObjectStringValidator.ContainsNonAsciiOrNonPrintableOrWhitespaceCharacters(this.Value))
27+
throw new ArgumentException($"A {nameof(Currency)} must consist of simple characters.");
28+
}
29+
}

DomainModeling.Example/Payment.cs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
namespace Architect.DomainModeling.Example;
22

3+
// An Entity identified by a PaymentId, the latter being a source-generated struct wrapping a string
34
// Use "Go To Definition" on the PaymentId type to view its source-generated implementation
4-
public class Payment : Entity<PaymentId, string> // Entity<PaymentId, string>: An Entity identified by a PaymentId, which is a source-generated struct wrapping a string
5+
[Entity<PaymentId, string>]
6+
public sealed class Payment : Entity<PaymentId> // Base class is optional, but offers ID-based equality and a decent ToString() override
57
{
6-
// A default ToString() property based on the type and the Id value is provided by the base class
7-
// Hash code and equality implementations based on the Id value are provided by the base class
8+
// Property Id is declared by base class
89

9-
// The Id property is provided by the base class
10-
11-
public string Currency { get; } // Note that Currency deserves its own value object in practice
10+
public Currency Currency { get; }
1211
public decimal Amount { get; }
1312

14-
public Payment(string currency, decimal amount)
15-
: base(new PaymentId(Guid.NewGuid().ToString("N"))) // ID generated on construction (see also: https://github.com/TheArchitectDev/Architect.Identities#distributed-ids)
13+
public Payment(
14+
Currency currency,
15+
decimal amount)
16+
: base(new PaymentId(Guid.CreateVersion7().ToString("N"))) // ID generated on construction (see also: https://github.com/TheArchitectDev/Architect.Identities#distributed-ids)
1617
{
17-
this.Currency = currency ?? throw new ArgumentNullException(nameof(currency));
18+
// Note how, thanks to the chosen types, it is hard to pass an invalid value
19+
// (The use of the "default" keyword for struct WrapperValueObjects is prevented by an analyzer)
20+
this.Currency = currency;
1821
this.Amount = amount;
1922
}
2023
}

DomainModeling.Example/PaymentDummyBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ public sealed partial class PaymentDummyBuilder
66
{
77
// The source-generated partial defines a default value for each property, along with a fluent method to change it
88

9-
private string Currency { get; set; } = "EUR"; // Since the source generator cannot guess a decent default currency, we specify it manually
9+
private Currency Currency { get; set; } = new Currency("EUR"); // Since the source generator cannot guess a decent default currency, we specify it manually
1010

1111
// The source-generated partial defines a Build() method that invokes the most visible, simplest parameterized constructor
1212
}

0 commit comments

Comments
 (0)