Skip to content

Commit c1b2d40

Browse files
author
Magne Helleborg
authored
EventHandler improvements (#232)
* StartFrom / StopAt feature for event handlers * Mutation analyzer for public aggregate methods. Checks for incorrect mutations outside of using events * EventHandler analyzers. Includes checks for visibility, date parsing, date logic & EventContext fix for Handlers
1 parent c0865a2 commit c1b2d40

21 files changed

Lines changed: 1059 additions & 41 deletions

Source/Analyzers/AggregateAnalyzer.cs

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ public class AggregateAnalyzer : DiagnosticAnalyzer
2828
DescriptorRules.Aggregate.MutationShouldBePrivate,
2929
DescriptorRules.Aggregate.MutationHasIncorrectNumberOfParameters,
3030
DescriptorRules.Aggregate.MutationsCannotProduceEvents,
31-
DescriptorRules.Events.MissingAttribute
31+
DescriptorRules.Events.MissingAttribute,
32+
DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState
3233
);
3334

3435
/// <inheritdoc />
@@ -53,7 +54,8 @@ static void AnalyzeAggregates(SyntaxNodeAnalysisContext context)
5354

5455
var handledEvents = CheckOnMethods(context, aggregateSymbol);
5556
CheckApplyInvocations(context, aggregateSyntax, handledEvents);
56-
CheckApplyInvocationsInOnMethods(context, aggregateSyntax, aggregateSymbol);
57+
CheckApplyInvocationsInOnMethods(context, aggregateSymbol);
58+
CheckMutationsInPublicMethods(context, aggregateSymbol);
5759
}
5860

5961

@@ -144,10 +146,8 @@ static void CheckApplyInvocations(SyntaxNodeAnalysisContext context, ClassDeclar
144146
}
145147
}
146148

147-
static void CheckApplyInvocationsInOnMethods(SyntaxNodeAnalysisContext context, ClassDeclarationSyntax aggregateClassSyntax, INamedTypeSymbol aggregateType)
149+
static void CheckApplyInvocationsInOnMethods(SyntaxNodeAnalysisContext context, INamedTypeSymbol aggregateType)
148150
{
149-
var semanticModel = context.SemanticModel;
150-
151151
var onMethods = aggregateType
152152
.GetMembers()
153153
.Where(member => member.Name.Equals("On"))
@@ -194,4 +194,62 @@ static void CheckApplyInvocationsInOnMethods(SyntaxNodeAnalysisContext context,
194194
}
195195
}
196196
}
197-
}
197+
198+
static void CheckMutationsInPublicMethods(SyntaxNodeAnalysisContext context, INamedTypeSymbol aggregateType)
199+
{
200+
var publicMethods = aggregateType
201+
.GetMembers()
202+
.Where(member => !member.Name.Equals("On"))
203+
.OfType<IMethodSymbol>()
204+
.Where(method => method.DeclaredAccessibility.HasFlag(Accessibility.Public))
205+
.ToArray();
206+
if (publicMethods.Length == 0)
207+
{
208+
return;
209+
}
210+
var walker = new MutationWalker(context, aggregateType);
211+
212+
foreach (var onMethod in publicMethods)
213+
{
214+
if (onMethod.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() is not MethodDeclarationSyntax syntax)
215+
{
216+
continue;
217+
}
218+
walker.Visit(syntax);
219+
}
220+
}
221+
222+
class MutationWalker : CSharpSyntaxWalker
223+
{
224+
readonly SyntaxNodeAnalysisContext _context;
225+
readonly INamedTypeSymbol _aggregateType;
226+
227+
public MutationWalker(SyntaxNodeAnalysisContext context, INamedTypeSymbol aggregateType)
228+
{
229+
_context = context;
230+
_aggregateType = aggregateType;
231+
}
232+
233+
public override void VisitAssignmentExpression(AssignmentExpressionSyntax node)
234+
{
235+
var leftExpression = node.Left;
236+
237+
if (leftExpression is IdentifierNameSyntax || leftExpression is MemberAccessExpressionSyntax)
238+
{
239+
var symbolInfo = _context.SemanticModel.GetSymbolInfo(leftExpression);
240+
if (symbolInfo.Symbol is IFieldSymbol || symbolInfo.Symbol is IPropertySymbol)
241+
{
242+
var containingType = symbolInfo.Symbol.ContainingType;
243+
if (containingType != null && SymbolEqualityComparer.Default.Equals(_aggregateType, containingType))
244+
{
245+
var diagnostic = Diagnostic.Create(DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState, leftExpression.GetLocation());
246+
_context.ReportDiagnostic(diagnostic);
247+
}
248+
}
249+
}
250+
251+
base.VisitAssignmentExpression(node);
252+
}
253+
254+
// You can also add other types of mutations like increments, decrements, method calls etc.
255+
}}

Source/Analyzers/AnalysisExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,5 @@ static bool MatchesName(this AttributeArgumentSyntax attributeArgument, string p
125125
{
126126
return attributeArgument.NameColon?.Name.Identifier.Text == parameterName;
127127
}
128+
128129
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) Dolittle. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
using System.Collections.Immutable;
5+
using System.Composition;
6+
using System.Linq;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.CodeAnalysis;
10+
using Microsoft.CodeAnalysis.CodeActions;
11+
using Microsoft.CodeAnalysis.CodeFixes;
12+
using Microsoft.CodeAnalysis.CSharp;
13+
using Microsoft.CodeAnalysis.CSharp.Syntax;
14+
15+
namespace Dolittle.SDK.Analyzers.CodeFixes;
16+
17+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AttributeMissingCodeFixProvider)), Shared]
18+
public class EventHandlerEventContextCodeFixProvider : CodeFixProvider
19+
{
20+
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(
21+
DiagnosticIds.EventHandlerMissingEventContext
22+
);
23+
24+
public override Task RegisterCodeFixesAsync(CodeFixContext context)
25+
{
26+
var document = context.Document;
27+
28+
foreach (var diagnostic in context.Diagnostics)
29+
{
30+
switch (diagnostic.Id)
31+
{
32+
case DiagnosticIds.EventHandlerMissingEventContext:
33+
context.RegisterCodeFix(
34+
CodeAction.Create(
35+
"Add EventContext parameter",
36+
ct => AddEventContextParameter(context, diagnostic, document, ct),
37+
nameof(EventHandlerEventContextCodeFixProvider) + ".AddEventContext"),
38+
diagnostic);
39+
break;
40+
}
41+
}
42+
43+
44+
return Task.CompletedTask;
45+
}
46+
47+
async Task<Document> AddEventContextParameter(CodeFixContext context, Diagnostic diagnostic, Document document, CancellationToken ct)
48+
{
49+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
50+
if (root is null) return document;
51+
52+
// Find the method declaration identified by the diagnostic.
53+
var methodDeclaration = GetMethodDeclaration(root, diagnostic);
54+
if (methodDeclaration is null)
55+
{
56+
return document;
57+
}
58+
59+
60+
var updatedRoot = root.ReplaceNode(methodDeclaration, WithEventContextParameter(methodDeclaration));
61+
var newRoot = EnsureNamespaceImported((CompilationUnitSyntax)updatedRoot, "Dolittle.SDK.Events");
62+
return document.WithSyntaxRoot(newRoot);
63+
}
64+
65+
/// <summary>
66+
/// Adds EventContext parameter to the method declaration
67+
/// </summary>
68+
/// <param name="methodDeclaration"></param>
69+
/// <returns></returns>
70+
MethodDeclarationSyntax WithEventContextParameter(MethodDeclarationSyntax methodDeclaration)
71+
{
72+
var existingParameters = methodDeclaration.ParameterList.Parameters;
73+
// Get the first parameter that is not the EventContext parameter
74+
var eventParameter = existingParameters.FirstOrDefault(parameter => parameter.Type?.ToString() != "EventContext");
75+
if (eventParameter is null)
76+
{
77+
return methodDeclaration;
78+
}
79+
80+
81+
var eventContextParameter = SyntaxFactory.Parameter(SyntaxFactory.Identifier("ctx")).WithType(SyntaxFactory.ParseTypeName("EventContext"));
82+
83+
var originalParameterList = methodDeclaration.ParameterList;
84+
var newParameterList = SyntaxFactory.ParameterList(
85+
SyntaxFactory.SeparatedList(
86+
new[]
87+
{
88+
eventParameter,
89+
eventContextParameter
90+
}
91+
)
92+
).WithLeadingTrivia(originalParameterList.GetLeadingTrivia())
93+
.WithTrailingTrivia(originalParameterList.GetTrailingTrivia());
94+
return methodDeclaration.WithParameterList(newParameterList);
95+
}
96+
97+
MethodDeclarationSyntax? GetMethodDeclaration(SyntaxNode root, Diagnostic diagnostic)
98+
{
99+
var diagnosticSpan = diagnostic.Location.SourceSpan;
100+
var methodDeclaration = root.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType<MethodDeclarationSyntax>().First();
101+
return methodDeclaration;
102+
}
103+
104+
public static CompilationUnitSyntax EnsureNamespaceImported(CompilationUnitSyntax root, string namespaceToInclude)
105+
{
106+
var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceToInclude));
107+
var existingUsings = root.Usings;
108+
109+
if (existingUsings.Any(u => u.Name?.ToFullString() == namespaceToInclude))
110+
{
111+
// Namespace is already imported.
112+
return root;
113+
}
114+
var lineEndingTrivia = root.DescendantTrivia().First(_ => _.IsKind(SyntaxKind.EndOfLineTrivia));
115+
usingDirective = usingDirective.WithTrailingTrivia(lineEndingTrivia);
116+
117+
return root.WithUsings(existingUsings.Add(usingDirective));
118+
}
119+
}

Source/Analyzers/DescriptorRules.cs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ namespace Dolittle.SDK.Analyzers;
77

88
static class DescriptorRules
99
{
10+
internal static readonly DiagnosticDescriptor InvalidTimestamp =
11+
new(
12+
DiagnosticIds.InvalidTimestampParameter,
13+
title: "Invalid DateTimeOffset format",
14+
messageFormat: "Value '{0}' should be a valid DateTimeOffset",
15+
DiagnosticCategories.Sdk,
16+
DiagnosticSeverity.Error,
17+
isEnabledByDefault: true,
18+
description: "The value should be a valid DateTimeOffset.");
19+
20+
internal static readonly DiagnosticDescriptor InvalidStartStopTimestamp =
21+
new(
22+
DiagnosticIds.InvalidStartStopTime,
23+
title: "Start is not before stop",
24+
messageFormat: "'{0}' should be before '{1}'",
25+
DiagnosticCategories.Sdk,
26+
DiagnosticSeverity.Error,
27+
isEnabledByDefault: true,
28+
description: "Start timestamp should be before stop timestamp.");
29+
1030
internal static readonly DiagnosticDescriptor InvalidIdentity =
1131
new(
1232
DiagnosticIds.AttributeInvalidIdentityRuleId,
@@ -26,7 +46,17 @@ static class DescriptorRules
2646
DiagnosticSeverity.Error,
2747
isEnabledByDefault: true,
2848
description: "Assign a unique identity in the attribute");
29-
49+
50+
internal static readonly DiagnosticDescriptor InvalidAccessibility =
51+
new(
52+
DiagnosticIds.InvalidAccessibility,
53+
title: "Invalid accessibility level",
54+
messageFormat: "{0} needs to be '{1}'",
55+
DiagnosticCategories.Sdk,
56+
DiagnosticSeverity.Warning,
57+
isEnabledByDefault: true,
58+
description: "Change the accessibility level to '{1}'.");
59+
3060
internal static class Events
3161
{
3262
internal static readonly DiagnosticDescriptor MissingAttribute =
@@ -38,6 +68,16 @@ internal static class Events
3868
DiagnosticSeverity.Error,
3969
isEnabledByDefault: true,
4070
description: "Mark the event with an EventTypeAttribute and assign an identifier to it");
71+
72+
internal static readonly DiagnosticDescriptor MissingEventContext =
73+
new(
74+
DiagnosticIds.EventHandlerMissingEventContext,
75+
title: "Handle method does not take EventContext as the second parameter",
76+
messageFormat: "{0} is missing EventContext argument",
77+
DiagnosticCategories.Sdk,
78+
DiagnosticSeverity.Error,
79+
isEnabledByDefault: true,
80+
description: "Add the EventContext as the second parameter to the Handle method");
4181
}
4282

4383
internal static class Aggregate
@@ -90,5 +130,14 @@ internal static class Aggregate
90130
DiagnosticSeverity.Error,
91131
isEnabledByDefault: true
92132
);
133+
134+
internal static readonly DiagnosticDescriptor PublicMethodsCannotMutateAggregateState = new(
135+
DiagnosticIds.PublicMethodsCannotMutateAggregateState,
136+
"Aggregates should only be mutated with events",
137+
"Public methods can not mutate the state of an aggregate. All mutations needs to be done via events.",
138+
DiagnosticCategories.Sdk,
139+
DiagnosticSeverity.Warning,
140+
isEnabledByDefault: true
141+
);
93142
}
94143
}

Source/Analyzers/DiagnosticIds.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ public static class DiagnosticIds
2020
/// </summary>
2121
public const string IdentityIsNotUniqueRuleId = "SDK0003";
2222

23+
/// <summary>
24+
/// Invalid timestamp.
25+
/// </summary>
26+
public const string InvalidTimestampParameter = "SDK0004";
27+
28+
/// <summary>
29+
/// Invalid timestamp.
30+
/// </summary>
31+
public const string InvalidStartStopTime = "SDK0005";
32+
33+
public const string InvalidAccessibility = "SDK0006";
34+
35+
public const string EventHandlerMissingEventContext = "SDK0007";
36+
2337
/// <summary>
2438
/// Aggregate missing the required Attribute.
2539
/// </summary>
@@ -44,4 +58,12 @@ public static class DiagnosticIds
4458
/// Apply can not be used in an On-method.
4559
/// </summary>
4660
public const string AggregateMutationsCannotProduceEvents = "AGG0005";
61+
62+
/// <summary>
63+
/// Public methods can not mutate the state of an aggregate.
64+
/// All mutations need to be done in On-methods.
65+
/// </summary>
66+
public const string PublicMethodsCannotMutateAggregateState = "AGG0006";
67+
68+
4769
}

Source/Analyzers/DolittleTypes.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,7 @@ static class DolittleTypes
1212

1313
public const string ICommitEventsInterface = "Dolittle.SDK.Events.Store.ICommitEvents";
1414

15+
public const string EventContext = "Dolittle.SDK.Events.EventContext";
16+
1517

1618
}

0 commit comments

Comments
 (0)