From 419f54103abf04b241cea04971d9ea8b9161bd70 Mon Sep 17 00:00:00 2001 From: Magne Helleborg Date: Wed, 6 Mar 2024 18:48:50 +0100 Subject: [PATCH] Projection analyzers --- Source/Analyzers/AnalysisExtensions.cs | 21 ++ Source/Analyzers/DescriptorRules.cs | 56 ++++ Source/Analyzers/DiagnosticIds.cs | 19 +- Source/Analyzers/DolittleTypes.cs | 9 +- Source/Analyzers/ProjectionsAnalyzer.cs | 172 ++++++++++++ Source/Testing/Testing.csproj | 4 + .../Diagnostics/ProjectionAnalyzerTests.cs | 244 ++++++++++++++++++ 7 files changed, 515 insertions(+), 10 deletions(-) create mode 100644 Source/Analyzers/ProjectionsAnalyzer.cs create mode 100644 Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs diff --git a/Source/Analyzers/AnalysisExtensions.cs b/Source/Analyzers/AnalysisExtensions.cs index a62d8e59..f1a89039 100644 --- a/Source/Analyzers/AnalysisExtensions.cs +++ b/Source/Analyzers/AnalysisExtensions.cs @@ -45,6 +45,27 @@ public static bool IsAggregateRoot(this INamedTypeSymbol typeSymbol) return false; } + + /// + /// Checks if base class of the type is Dolittle.SDK.Projections.ProjectionBase + /// + /// The checked class + /// + public static bool IsProjection(this INamedTypeSymbol typeSymbol) + { + var baseType = typeSymbol.BaseType; + while (baseType != null) + { + if (baseType.ToString() == DolittleTypes.ProjectionBaseClass) + { + return true; + } + + baseType = baseType.BaseType; + } + + return false; + } public static bool HasEventTypeAttribute(this ITypeSymbol type) => type.HasAttribute(DolittleTypes.EventTypeAttribute); public static bool HasAggregateRootAttribute(this ITypeSymbol type) => type.HasAttribute(DolittleTypes.AggregateRootAttribute); diff --git a/Source/Analyzers/DescriptorRules.cs b/Source/Analyzers/DescriptorRules.cs index 3989fcd8..72153d94 100644 --- a/Source/Analyzers/DescriptorRules.cs +++ b/Source/Analyzers/DescriptorRules.cs @@ -140,4 +140,60 @@ internal static class Aggregate isEnabledByDefault: true ); } + + internal static class Projection + { + internal static readonly DiagnosticDescriptor MissingAttribute = + new( + DiagnosticIds.ProjectionMissingAttributeRuleId, + title: "Class does not have the correct identifying ProjectionAttribute", + messageFormat: "'{0}' is missing ProjectionAttribute", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Mark the class with an attribute to assign an identifier to it"); + + // ProjectionMissingBaseClassRuleId + + internal static readonly DiagnosticDescriptor MissingBaseClass = + new( + DiagnosticIds.ProjectionMissingBaseClassRuleId, + title: "Projection does not inherit from ProjectionBase", + messageFormat: "'{0}' does not inherit from ProjectionBase", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Inherit from ProjectionBase."); + + internal static readonly DiagnosticDescriptor InvalidOnMethodParameters = + new( + DiagnosticIds.ProjectionInvalidOnMethodParametersRuleId, + title: "Invalid On-method", + messageFormat: "'{0}' is invalid", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Change the On-method to match the required signature. The method should take an event as first parameter and a ProjectionContext as the second parameter."); + + internal static readonly DiagnosticDescriptor InvalidOnMethodReturnType = + new( + DiagnosticIds.ProjectionInvalidOnMethodReturnTypeRuleId, + title: "Invalid On-method return type", + messageFormat: "'{0}' returns an invalid type", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Change the On-method to return void, ProjectionResultType or ProjectionResult."); + + internal static readonly DiagnosticDescriptor EventTypeAlreadyHandled = + new( + DiagnosticIds.ProjectionDuplicateEventHandler, + title: "Event type already handled", + messageFormat: "'{0}' is already handled", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The event type is already handled by another On-method."); + } + } diff --git a/Source/Analyzers/DiagnosticIds.cs b/Source/Analyzers/DiagnosticIds.cs index 8b919185..2ead11ea 100644 --- a/Source/Analyzers/DiagnosticIds.cs +++ b/Source/Analyzers/DiagnosticIds.cs @@ -14,7 +14,7 @@ public static class DiagnosticIds /// Attribute missing the required ID. /// public const string EventMissingAttributeRuleId = "SDK0002"; - + /// /// Identity is shared between multiple targets. /// @@ -24,16 +24,16 @@ public static class DiagnosticIds /// Invalid timestamp. /// public const string InvalidTimestampParameter = "SDK0004"; - + /// /// Invalid timestamp. /// public const string InvalidStartStopTime = "SDK0005"; public const string InvalidAccessibility = "SDK0006"; - + public const string EventHandlerMissingEventContext = "SDK0007"; - + /// /// Aggregate missing the required Attribute. /// @@ -53,17 +53,22 @@ public static class DiagnosticIds /// Aggregate On-method has an incorrect number of parameters /// public const string AggregateMutationShouldBePrivateRuleId = "AGG0004"; - + /// /// Apply can not be used in an On-method. /// public const string AggregateMutationsCannotProduceEvents = "AGG0005"; - + /// /// Public methods can not mutate the state of an aggregate. /// All mutations need to be done in On-methods. /// public const string PublicMethodsCannotMutateAggregateState = "AGG0006"; - + + public const string ProjectionMissingAttributeRuleId = "PROJ0001"; + public const string ProjectionMissingBaseClassRuleId = "PROJ0002"; + public const string ProjectionInvalidOnMethodParametersRuleId = "PROJ0003"; + public const string ProjectionInvalidOnMethodReturnTypeRuleId = "PROJ0004"; + public const string ProjectionDuplicateEventHandler = "PROJ0005"; } diff --git a/Source/Analyzers/DolittleTypes.cs b/Source/Analyzers/DolittleTypes.cs index 5417d5e6..c18ad11b 100644 --- a/Source/Analyzers/DolittleTypes.cs +++ b/Source/Analyzers/DolittleTypes.cs @@ -9,10 +9,13 @@ static class DolittleTypes public const string EventTypeAttribute = "Dolittle.SDK.Events.EventTypeAttribute"; public const string AggregateRootAttribute = "Dolittle.SDK.Aggregates.AggregateRootAttribute"; public const string EventHandlerAttribute = "Dolittle.SDK.Events.Handling.EventHandlerAttribute"; - + public const string ICommitEventsInterface = "Dolittle.SDK.Events.Store.ICommitEvents"; - + public const string EventContext = "Dolittle.SDK.Events.EventContext"; - + public const string ProjectionBaseClass = "Dolittle.SDK.Projections.ProjectionBase"; + public const string ProjectionAttribute = "Dolittle.SDK.Projections.ProjectionAttribute"; + public const string ProjectionResultType = "Dolittle.SDK.Projections.ProjectionResultType"; + public const string ProjectionContextType = "Dolittle.SDK.Projections.ProjectionContext"; } diff --git a/Source/Analyzers/ProjectionsAnalyzer.cs b/Source/Analyzers/ProjectionsAnalyzer.cs new file mode 100644 index 00000000..395f873e --- /dev/null +++ b/Source/Analyzers/ProjectionsAnalyzer.cs @@ -0,0 +1,172 @@ +// Copyright (c) Dolittle. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dolittle.SDK.Analyzers; + +#pragma warning disable CS1574, CS1584, CS1581, CS1580 +/// +/// Analyzer for . +/// +#pragma warning restore CS1574, CS1584, CS1581, CS1580 +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class ProjectionsAnalyzer : DiagnosticAnalyzer +{ + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + // DescriptorRules.DuplicateIdentity, + DescriptorRules.Events.MissingAttribute, + DescriptorRules.Projection.MissingAttribute, + DescriptorRules.Projection.MissingBaseClass, + DescriptorRules.Projection.InvalidOnMethodParameters, + DescriptorRules.Projection.InvalidOnMethodReturnType, + DescriptorRules.Projection.EventTypeAlreadyHandled + ); + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeProjections, ImmutableArray.Create(SyntaxKind.ClassDeclaration)); + } + + + static void AnalyzeProjections(SyntaxNodeAnalysisContext context) + { + // Check if the symbol has the projection root base class + var projectionSyntax = (ClassDeclarationSyntax)context.Node; + // Check if the symbol has the projection root base class + var projectionSymbol = context.SemanticModel.GetDeclaredSymbol(projectionSyntax); + if (projectionSymbol?.IsProjection() != true) return; + + CheckProjectionAttributePresent(context, projectionSymbol); + CheckOnMethods(context, projectionSymbol); + } + + + static void CheckOnMethods(SyntaxNodeAnalysisContext context, INamedTypeSymbol projectionType) + { + var members = projectionType.GetMembers(); + var onMethods = members.Where(_ => _.Name.Equals("On")).OfType().ToArray(); + var eventTypesHandled = new HashSet(SymbolEqualityComparer.Default); + + foreach (var onMethod in onMethods) + { + if (onMethod.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() is not MethodDeclarationSyntax syntax) continue; + + // if (syntax.Modifiers.Any(SyntaxKind.PublicKeyword) + // || syntax.Modifiers.Any(SyntaxKind.InternalKeyword) + // || syntax.Modifiers.Any(SyntaxKind.ProtectedKeyword)) + // { + // context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.Projection.MutationShouldBePrivate, syntax.GetLocation(), + // onMethod.ToDisplayString())); + // } + + var parameters = onMethod.Parameters; + if (parameters.Length is not 1 and not 2) + { + context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.Projection.InvalidOnMethodParameters, syntax.GetLocation(), + onMethod.ToDisplayString())); + } + + if (parameters.Length > 0) + { + var eventType = parameters[0].Type; + if(!eventTypesHandled.Add(eventType)) + { + context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.Projection.EventTypeAlreadyHandled, syntax.GetLocation(), + eventType.ToDisplayString())); + } + + if (!eventType.HasEventTypeAttribute()) + { + context.ReportDiagnostic(Diagnostic.Create( + DescriptorRules.Events.MissingAttribute, + parameters[0].DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax().GetLocation(), + eventType.ToTargetClassAndAttributeProps(DolittleTypes.EventTypeAttribute), + eventType.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat)) + ); + } + } + + if (parameters.Length > 1) + { + var secondParameterTypeSymbol = parameters[1].Type; + var contextType = secondParameterTypeSymbol.ToDisplayString(); + if (!contextType.Equals(DolittleTypes.ProjectionContextType, StringComparison.Ordinal) + // && !contextType.Equals(DolittleTypes.EventContext, StringComparison.Ordinal) + ) + { + var loc = parameters[1].DeclaringSyntaxReferences.First().GetSyntax().GetLocation(); + context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.Projection.InvalidOnMethodParameters, loc, + onMethod.ToDisplayString())); + } + + } + + CheckOnReturnType(context, projectionType, onMethod, syntax); + } + } + + static void CheckOnReturnType(SyntaxNodeAnalysisContext context, INamedTypeSymbol projectionType, IMethodSymbol onMethod, + MethodDeclarationSyntax syntax) + { + // Check for valid return type. Valid types are void, ProjectionResultType and ProjectionResult<> + var returnType = onMethod.ReturnType; + if(returnType.SpecialType == SpecialType.System_Void) + { + return; // void is valid + } + + if (returnType is not INamedTypeSymbol namedReturnType) + { + context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.Projection.InvalidOnMethodReturnType, syntax.GetLocation(), + onMethod.ToDisplayString())); + return; + } + + if (namedReturnType.IsGenericType) + { + var genericType = namedReturnType.TypeArguments[0]; + if (!genericType.Equals(projectionType)) + { + context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.Projection.InvalidOnMethodReturnType, syntax.GetLocation(), + onMethod.ToDisplayString())); + } + } + else + { + if (namedReturnType.ToDisplayString() != DolittleTypes.ProjectionResultType) + { + context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.Projection.InvalidOnMethodReturnType, syntax.GetLocation(), + onMethod.ToDisplayString())); + } + } + } + + static void CheckProjectionAttributePresent(SyntaxNodeAnalysisContext context, INamedTypeSymbol projectionClass) + { + var hasAttribute = projectionClass.GetAttributes() + .Any(attribute => attribute.AttributeClass?.ToDisplayString().Equals(DolittleTypes.ProjectionAttribute, StringComparison.Ordinal) == true); + + if (!hasAttribute) + { + context.ReportDiagnostic(Diagnostic.Create( + DescriptorRules.Projection.MissingAttribute, + projectionClass.Locations[0], + projectionClass.ToTargetClassAndAttributeProps(DolittleTypes.ProjectionAttribute), + projectionClass.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + )); + } + } +} diff --git a/Source/Testing/Testing.csproj b/Source/Testing/Testing.csproj index c66a4236..2ffecff3 100644 --- a/Source/Testing/Testing.csproj +++ b/Source/Testing/Testing.csproj @@ -14,5 +14,9 @@ + + + + diff --git a/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs b/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs new file mode 100644 index 00000000..126e8171 --- /dev/null +++ b/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs @@ -0,0 +1,244 @@ +// Copyright (c) Dolittle. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + + +using System.Threading.Tasks; + +namespace Dolittle.SDK.Analyzers.Diagnostics; + +public class ProjectionAnalyzerTests : AnalyzerTest +{ + [Fact] + public async Task ShouldFindNoIssues() + { + var test = @" +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +record NameUpdated(string Name); + +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection: ProjectionBase +{ + public string Name {get; set;} + + private void On(NameUpdated evt) + { + Name = evt.Name; + } +}"; + await VerifyAnalyzerFindsNothingAsync(test); + } + + [Fact] + public async Task ShouldFindNoIssuesWithProjectionContext() + { + var test = @" +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +record NameUpdated(string Name); + +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection: ProjectionBase +{ + public string Name {get; set;} + + private void On(NameUpdated evt, ProjectionContext ctx) + { + Name = evt.Name; + } +}"; + await VerifyAnalyzerFindsNothingAsync(test); + } + + [Fact] + public async Task ShouldFindNoIssuesWithEventContext() + { + var test = @" +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +record NameUpdated(string Name); + +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection: ProjectionBase +{ + public string Name {get; set;} + + private void On(NameUpdated evt, EventContext ctx) + { + Name = evt.Name; + } +}"; + await VerifyAnalyzerFindsNothingAsync(test); + } + +// [Fact] +// public async Task ShouldFindNonPrivateOnMethod() +// { +// var test = @" +// using Dolittle.SDK.Projections; +// using Dolittle.SDK.Events; +// +// +// [EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +// record NameUpdated(string Name); +// +// [Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +// class SomeProjection: ProjectionBase +// { +// public string Name {get; set;} +// +// public void UpdateName(string name) +// { +// Apply(new NameUpdated(name)); +// } +// +// public void On(NameUpdated evt) +// { +// Name = evt.Name; +// } +// }"; +// +// DiagnosticResult[] expected = +// { +// Diagnostic(DescriptorRules.Projection.MutationShouldBePrivate) +// .WithSpan(19, 5, 22, 6) +// .WithArguments("SomeProjection.On(NameUpdated)") +// }; +// +// await VerifyAnalyzerAsync(test, expected); +// } + + [Fact] + public async Task ShouldFindOnMethodWithIncorrectParameters() + { + var test = @" +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +record NameUpdated(string Name); + +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection: ProjectionBase +{ + public string Name {get; set;} + + + private void On(NameUpdated evt, string shouldNotBeHere) + { + Name = evt.Name; + } +}"; + + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Projection.InvalidOnMethodParameters) + .WithSpan(15, 38, 15, 60) + .WithArguments("SomeProjection.On(NameUpdated, string)") + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldFindOnMethodWithNoParameters() + { + var test = @" +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +record NameUpdated(string Name); + +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection: ProjectionBase +{ + public string Name {get; set;} + + void On() + { + } +}"; + + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Projection.InvalidOnMethodParameters) + .WithSpan(14, 5, 16, 6) + .WithArguments("SomeProjection.On()") + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldFindMissingProjectionAttribute() + { + var test = @" +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +record NameUpdated(string Name); + +class SomeProjection: ProjectionBase +{ + public string Name {get; set;} + + private void On(NameUpdated evt) + { + Name = evt.Name; + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Projection.MissingAttribute) + .WithSpan(9, 7, 9, 21) + .WithArguments("SomeProjection") + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldFindMissingEventAttribute() + { + var test = @" +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + +namespace Test; + +record NameUpdated(string Name); + +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection: ProjectionBase +{ + public string Name {get; set;} + + private void On(NameUpdated evt) + { + Name = evt.Name; + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Events.MissingAttribute) + .WithSpan(14, 21, 14, 36) + .WithArguments("Test.NameUpdated"), + }; + + await VerifyAnalyzerAsync(test, expected); + } +}