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..e7a2efb5
--- /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);
+ }
+}