diff --git a/Benchmarks/SDK/SDK.csproj b/Benchmarks/SDK/SDK.csproj index 94b12c0f..3b0682ae 100644 --- a/Benchmarks/SDK/SDK.csproj +++ b/Benchmarks/SDK/SDK.csproj @@ -10,7 +10,7 @@ - + diff --git a/Source/Analyzers/AggregateAnalyzer.cs b/Source/Analyzers/AggregateAnalyzer.cs index dbf344f1..45636240 100644 --- a/Source/Analyzers/AggregateAnalyzer.cs +++ b/Source/Analyzers/AggregateAnalyzer.cs @@ -32,7 +32,8 @@ public class AggregateAnalyzer : DiagnosticAnalyzer DescriptorRules.Aggregate.MutationHasIncorrectNumberOfParameters, DescriptorRules.Aggregate.MutationsCannotProduceEvents, DescriptorRules.Events.MissingAttribute, - DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState + DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState, + DescriptorRules.Aggregate.MutationsCannotUseCurrentTime ); /// @@ -86,6 +87,8 @@ static HashSet CheckOnMethods(SyntaxNodeAnalysisContext context, IN context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.Aggregate.MutationHasIncorrectNumberOfParameters, syntax.GetLocation(), onMethod.ToDisplayString())); } + EnsureMutationDoesNotAccessCurrentTime(context, syntax); + if (parameters.Length > 0) { @@ -107,6 +110,42 @@ static HashSet CheckOnMethods(SyntaxNodeAnalysisContext context, IN return eventTypesHandled; } + /// + /// Checks if the method gets the current time via DateTime or DateTimeOffset + /// Since this is not allowed for the mutations, we need to report a diagnostic + /// + /// + /// + static void EnsureMutationDoesNotAccessCurrentTime(SyntaxNodeAnalysisContext context, MethodDeclarationSyntax onMethod) + { + var currentTimeInvocations = onMethod.DescendantNodes() + .OfType() + .Where(memberAccess => + { + var now = memberAccess.Name + is IdentifierNameSyntax { Identifier.Text: "Now" } + or IdentifierNameSyntax { Identifier.Text: "UtcNow" }; + if (!now) + { + return false; + } + + var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression); + // Check if the type is DateTime or DateTimeOffset + return typeInfo.Type?.ToDisplayString() == "System.DateTime" || typeInfo.Type?.ToDisplayString() == "System.DateTimeOffset"; + + }).ToArray(); + + foreach (var currentTimeInvocation in currentTimeInvocations) + { + context.ReportDiagnostic(Diagnostic.Create( + DescriptorRules.Aggregate.MutationsCannotUseCurrentTime, + currentTimeInvocation.GetLocation(), + new[] { currentTimeInvocation.ToFullString() } + )); + } + } + static void CheckAggregateRootAttributePresent(SyntaxNodeAnalysisContext context, INamedTypeSymbol aggregateClass) { var hasAttribute = aggregateClass.GetAttributes() diff --git a/Source/Analyzers/Analyzers.csproj b/Source/Analyzers/Analyzers.csproj index 01b33544..6c669c97 100644 --- a/Source/Analyzers/Analyzers.csproj +++ b/Source/Analyzers/Analyzers.csproj @@ -12,7 +12,7 @@ - + diff --git a/Source/Analyzers/CodeFixes/AggregateMutationCodeFixProvider.cs b/Source/Analyzers/CodeFixes/AggregateMutationCodeFixProvider.cs index 400204fe..96b0069e 100644 --- a/Source/Analyzers/CodeFixes/AggregateMutationCodeFixProvider.cs +++ b/Source/Analyzers/CodeFixes/AggregateMutationCodeFixProvider.cs @@ -16,13 +16,20 @@ namespace Dolittle.SDK.Analyzers.CodeFixes; /// -/// Adds On-method for the specific event type +/// Adds On-method (mutation) for the specific event type /// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AggregateMutationCodeFixProvider)), Shared] public class AggregateMutationCodeFixProvider : CodeFixProvider { /// - public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.AggregateMissingMutationRuleId); + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DiagnosticIds.AggregateMissingMutationRuleId); + + /// + /// Gets the fix all provider + /// + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; /// public override Task RegisterCodeFixesAsync(CodeFixContext context) @@ -48,7 +55,7 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context) return Task.CompletedTask; } - async Task GenerateStub(CodeFixContext context, Document document, string eventType, CancellationToken ct) + static async Task GenerateStub(CodeFixContext context, Document document, string eventType, CancellationToken ct) { if (await context.Document.GetSyntaxRootAsync(ct) is not CompilationUnitSyntax root) return document; @@ -56,7 +63,8 @@ async Task GenerateStub(CodeFixContext context, Document document, str if (member is not MethodDeclarationSyntax method) return document; - var classDeclaration = root.DescendantNodes().OfType().First(declaration => declaration.Span.Contains(context.Span)); + var classDeclaration = root.DescendantNodes().OfType() + .First(declaration => declaration.Span.Contains(context.Span)); var replacedNode = root.ReplaceNode(classDeclaration, Formatter.Format(WithMutationMethod(classDeclaration, method), document.Project.Solution.Workspace)); diff --git a/Source/Analyzers/CodeFixes/AttributeIdentityCodeFixProvider.cs b/Source/Analyzers/CodeFixes/AttributeIdentityCodeFixProvider.cs index 94ee395a..35f88bb5 100644 --- a/Source/Analyzers/CodeFixes/AttributeIdentityCodeFixProvider.cs +++ b/Source/Analyzers/CodeFixes/AttributeIdentityCodeFixProvider.cs @@ -21,7 +21,7 @@ public class AttributeIdentityCodeFixProvider : CodeFixProvider { /// public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.AttributeInvalidIdentityRuleId); - + /// public override Task RegisterCodeFixesAsync(CodeFixContext context) { @@ -31,7 +31,6 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context) { return Task.CompletedTask; } - switch (diagnostic.Id) { case DiagnosticIds.AttributeInvalidIdentityRuleId: diff --git a/Source/Analyzers/CodeFixes/AttributeMissingCodeFixProvider.cs b/Source/Analyzers/CodeFixes/AttributeMissingCodeFixProvider.cs index 17619fdb..76b4e716 100644 --- a/Source/Analyzers/CodeFixes/AttributeMissingCodeFixProvider.cs +++ b/Source/Analyzers/CodeFixes/AttributeMissingCodeFixProvider.cs @@ -25,7 +25,7 @@ public class AttributeMissingCodeFixProvider : CodeFixProvider DiagnosticIds.AggregateMissingAttributeRuleId, DiagnosticIds.EventMissingAttributeRuleId, DiagnosticIds.ProjectionMissingAttributeRuleId); - + /// public override Task RegisterCodeFixesAsync(CodeFixContext context) { diff --git a/Source/Analyzers/CodeFixes/CodeFixExtensions.cs b/Source/Analyzers/CodeFixes/CodeFixExtensions.cs index 1a58d2a7..b7fa2971 100644 --- a/Source/Analyzers/CodeFixes/CodeFixExtensions.cs +++ b/Source/Analyzers/CodeFixes/CodeFixExtensions.cs @@ -13,19 +13,24 @@ static class Extensions { static readonly LineEndingsRewriter _lineEndingsRewriter = new(); - public static T WithLfLineEndings(this T replacedNode) where T : SyntaxNode => (T)_lineEndingsRewriter.Visit(replacedNode); + public static T WithLfLineEndings(this T replacedNode) where T : SyntaxNode => + (T)_lineEndingsRewriter.Visit(replacedNode); - public static CompilationUnitSyntax AddMissingUsingDirectives(this CompilationUnitSyntax root, params INamespaceSymbol[] namespaces) + public static CompilationUnitSyntax AddMissingUsingDirectives(this CompilationUnitSyntax root, + params INamespaceSymbol[] namespaces) { - return root.AddMissingUsingDirectives(namespaces.Select(_ => _.ToDisplayString()).ToArray()); + return root.AddMissingUsingDirectives(namespaces.Select(it => it.ToDisplayString()).ToArray()); } - public static CompilationUnitSyntax AddMissingUsingDirectives(this CompilationUnitSyntax root, params string[] namespaces) + + public static CompilationUnitSyntax AddMissingUsingDirectives(this CompilationUnitSyntax root, + params string[] namespaces) { var usingDirectives = root.DescendantNodes().OfType().ToList(); var nonImportedNamespaces = namespaces.ToImmutableHashSet(); foreach (var usingDirective in usingDirectives) { + if (usingDirective.Name is null) continue; var usingDirectiveName = usingDirective.Name.ToString(); if (nonImportedNamespaces.Contains(usingDirectiveName)) { @@ -35,8 +40,9 @@ public static CompilationUnitSyntax AddMissingUsingDirectives(this CompilationUn if (!nonImportedNamespaces.Any()) return root; - var newUsingDirectives = nonImportedNamespaces.Select(namespaceName => SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName))).ToArray(); - + var newUsingDirectives = nonImportedNamespaces + .Select(namespaceName => SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName))).ToArray(); + return root.AddUsings(newUsingDirectives).NormalizeWhitespace(); } } diff --git a/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs b/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs index 9a428e8f..a715e371 100644 --- a/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs +++ b/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs @@ -14,13 +14,21 @@ namespace Dolittle.SDK.Analyzers.CodeFixes; -[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AttributeMissingCodeFixProvider)), Shared] +/// +/// Code fix provider for adding EventContext parameter to event handler methods +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(EventHandlerEventContextCodeFixProvider)), Shared] public class EventHandlerEventContextCodeFixProvider : CodeFixProvider { + /// public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create( DiagnosticIds.EventHandlerMissingEventContext ); + /// inheritdoc + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// inheritdoc public override Task RegisterCodeFixesAsync(CodeFixContext context) { var document = context.Document; @@ -44,7 +52,7 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context) return Task.CompletedTask; } - async Task AddEventContextParameter(CodeFixContext context, Diagnostic diagnostic, Document document, CancellationToken ct) + async Task AddEventContextParameter(CodeFixContext context, Diagnostic diagnostic, Document document, CancellationToken _) { var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); if (root is null) return document; @@ -67,7 +75,7 @@ async Task AddEventContextParameter(CodeFixContext context, Diagnostic /// /// /// - MethodDeclarationSyntax WithEventContextParameter(MethodDeclarationSyntax methodDeclaration) + static MethodDeclarationSyntax WithEventContextParameter(MethodDeclarationSyntax methodDeclaration) { var existingParameters = methodDeclaration.ParameterList.Parameters; // Get the first parameter that is not the EventContext parameter @@ -101,7 +109,7 @@ MethodDeclarationSyntax WithEventContextParameter(MethodDeclarationSyntax method return methodDeclaration; } - public static CompilationUnitSyntax EnsureNamespaceImported(CompilationUnitSyntax root, string namespaceToInclude) + static CompilationUnitSyntax EnsureNamespaceImported(CompilationUnitSyntax root, string namespaceToInclude) { var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceToInclude)); var existingUsings = root.Usings; @@ -111,7 +119,7 @@ public static CompilationUnitSyntax EnsureNamespaceImported(CompilationUnitSynta // Namespace is already imported. return root; } - var lineEndingTrivia = root.DescendantTrivia().First(_ => _.IsKind(SyntaxKind.EndOfLineTrivia)); + var lineEndingTrivia = root.DescendantTrivia().First(it => it.IsKind(SyntaxKind.EndOfLineTrivia)); usingDirective = usingDirective.WithTrailingTrivia(lineEndingTrivia); return root.WithUsings(existingUsings.Add(usingDirective)); diff --git a/Source/Analyzers/CodeFixes/MethodVisibilityCodeFixProvider.cs b/Source/Analyzers/CodeFixes/MethodVisibilityCodeFixProvider.cs index aea118c1..de4a347d 100644 --- a/Source/Analyzers/CodeFixes/MethodVisibilityCodeFixProvider.cs +++ b/Source/Analyzers/CodeFixes/MethodVisibilityCodeFixProvider.cs @@ -13,15 +13,21 @@ namespace Dolittle.SDK.Analyzers.CodeFixes; +/// +/// Fixes the visibility of a method +/// [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MethodVisibilityCodeFixProvider))] public class MethodVisibilityCodeFixProvider : CodeFixProvider { + /// public sealed override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(DiagnosticIds.InvalidAccessibility, DiagnosticIds.AggregateMutationShouldBePrivateRuleId); - public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + /// public sealed override Task RegisterCodeFixesAsync(CodeFixContext context) { var diagnostic = context.Diagnostics.First(); @@ -43,7 +49,7 @@ public sealed override Task RegisterCodeFixesAsync(CodeFixContext context) return Task.CompletedTask; } - void RegisterMakePublicCodeFix(CodeFixContext context, Diagnostic diagnostic) + static void RegisterMakePublicCodeFix(CodeFixContext context, Diagnostic diagnostic) { context.RegisterCodeFix( CodeAction.Create( @@ -53,7 +59,7 @@ void RegisterMakePublicCodeFix(CodeFixContext context, Diagnostic diagnostic) diagnostic); } - void RegisterMakePrivateCodeFix(CodeFixContext context, Diagnostic diagnostic) + static void RegisterMakePrivateCodeFix(CodeFixContext context, Diagnostic diagnostic) { context.RegisterCodeFix( CodeAction.Create( @@ -63,7 +69,7 @@ void RegisterMakePrivateCodeFix(CodeFixContext context, Diagnostic diagnostic) diagnostic); } - async Task MakePublicAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken cancellationToken) + static async Task MakePublicAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken cancellationToken) { var syntaxRoot = await context.Document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); @@ -79,25 +85,25 @@ async Task MakePublicAsync(CodeFixContext context, Diagnostic diagnost // If exists remove it if (modifier != null) { - newMethod = methodDecl.WithModifiers(methodDecl + newMethod = newMethod.WithModifiers(methodDecl .Modifiers.Remove(modifier) .Add(SyntaxFactory.Token(SyntaxKind.PublicKeyword))) .WithLeadingTrivia(modifier.LeadingTrivia); } else { + // Add the 'public' modifier var newModifiers = methodDecl.Modifiers.Add(SyntaxFactory.Token(SyntaxKind.PublicKeyword)); - newMethod = methodDecl.WithModifiers(newModifiers); + newMethod = newMethod.WithModifiers(newModifiers); } - // Add the 'public' modifier var newSyntaxRoot = syntaxRoot.ReplaceNode(methodDecl, newMethod); return context.Document.WithSyntaxRoot(newSyntaxRoot); } - async Task MakePrivateAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken cancellationToken) + static async Task MakePrivateAsync(CodeFixContext context, Diagnostic diagnostic, CancellationToken cancellationToken) { var syntaxRoot = await context.Document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); diff --git a/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs b/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs index 0bab2c7a..b9ec7707 100644 --- a/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs +++ b/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs @@ -13,11 +13,19 @@ namespace Dolittle.SDK.Analyzers.CodeFixes; -[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MethodVisibilityCodeFixProvider))] +/// +/// Codefix provider for adding missing base classes according to the diagnostic +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MissingBaseClassCodeFixProvider))] public class MissingBaseClassCodeFixProvider : CodeFixProvider { + /// public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.MissingBaseClassRuleId); + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// public override Task RegisterCodeFixesAsync(CodeFixContext context) { var diagnostic = context.Diagnostics.First(); @@ -40,7 +48,7 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context) - async Task AddBaseClassAsync(Document document, Diagnostic diagnostic, string missingClass, CancellationToken cancellationToken) + static async Task AddBaseClassAsync(Document document, Diagnostic diagnostic, string missingClass, CancellationToken cancellationToken) { var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); @@ -56,7 +64,7 @@ async Task AddBaseClassAsync(Document document, Diagnostic diagnostic, var newClassDeclaration = classDeclaration.AddBaseListTypes(SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName(baseClassType.Name))).NormalizeWhitespace(); // Add using directive for the namespace if it's not already there - var namespaceToAdd = baseClassType?.ContainingNamespace?.ToDisplayString(); + var namespaceToAdd = baseClassType.ContainingNamespace?.ToDisplayString(); if (!string.IsNullOrEmpty(namespaceToAdd) && root is CompilationUnitSyntax compilationUnitSyntax && !NamespaceImported(compilationUnitSyntax, namespaceToAdd!)) { @@ -72,7 +80,7 @@ async Task AddBaseClassAsync(Document document, Diagnostic diagnostic, - bool NamespaceImported(CompilationUnitSyntax root, string namespaceName) + static bool NamespaceImported(CompilationUnitSyntax root, string namespaceName) { return root.Usings.Any(usingDirective => usingDirective.Name?.ToString() == namespaceName); } diff --git a/Source/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProvider.cs b/Source/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProvider.cs new file mode 100644 index 00000000..cee61cc8 --- /dev/null +++ b/Source/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProvider.cs @@ -0,0 +1,158 @@ +// Copyright (c) Dolittle. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Dolittle.SDK.Analyzers.CodeFixes; + +/// +/// Code fix provider for adding EventContext parameter to event handler methods +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ProjectionMutationEventTimeCodeFixProvider)), Shared] +public class ProjectionMutationEventTimeCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create( + DiagnosticIds.ProjectionMutationUsedCurrentTime + ); + + /// inheritdoc + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// inheritdoc + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + var document = context.Document; + + foreach (var diagnostic in context.Diagnostics) + { + switch (diagnostic.Id) + { + case DiagnosticIds.ProjectionMutationUsedCurrentTime: + context.RegisterCodeFix( + CodeAction.Create( + "Replace with EventContext.Occurred", + ct => ReplaceDateTimeNowWithEventContextOccurred(context, diagnostic, document, ct), + nameof(ProjectionMutationEventTimeCodeFixProvider) + ".UseEventContext"), + diagnostic); + break; + } + } + + + return Task.CompletedTask; + } + + async Task ReplaceDateTimeNowWithEventContextOccurred(CodeFixContext context, Diagnostic diagnostic, + Document document, CancellationToken _) + { + if (!diagnostic.Properties.TryGetValue("expression", out var expression)) + { + return document; + } + + // Keep the type / kind of the expression + var replacement = expression switch + { + "System.DateTime.Now" => "Occurred.DateTime", + "System.DateTime.UtcNow" => "Occurred.UtcDateTime", + "System.DateTimeOffset.Now" => "Occurred", + "System.DateTimeOffset.UtcNow" => "Occurred", + _ => null + }; + + if (replacement is null) + { + return document; + } + + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) return document; + + var existingMethod = GetMethodDeclaration(root, diagnostic); + if (existingMethod is null) return document; + var dateTimeNode = root.FindNode(diagnostic.Location.SourceSpan); + + var (parameterList, eventContextParameterName) = GetParameterListAndContextName(existingMethod.ParameterList); + + + root = root.ReplaceNodes(new[] { existingMethod.ParameterList, dateTimeNode }, (original, _) => + { + if (original == existingMethod.ParameterList) + { + return parameterList; + } + + return SyntaxFactory.ParseExpression(eventContextParameterName + "." + replacement); + }); + + + root = EnsureNamespaceImported((CompilationUnitSyntax)root, "Dolittle.SDK.Events"); + + return document.WithSyntaxRoot(root.NormalizeWhitespace(eol:"\n")); + } + + static (ParameterListSyntax, string eventContextParameterName) GetParameterListAndContextName( + ParameterListSyntax parameterList) + { + if (parameterList.Parameters.Count < 2) // Missing EventContext parameter + { + // Add the EventContext parameter + return (WithEventContextParameter(parameterList), "ctx"); + } + + // Get the parameter name + var parameterName = parameterList.Parameters[1].Identifier.Text; + return (parameterList, parameterName); + } + + static ParameterListSyntax WithEventContextParameter(ParameterListSyntax parameterList, string parameterName = "ctx") + { + var existingParameters = parameterList.Parameters; + // Get the first parameter that is not the EventContext parameter + var eventParameter = existingParameters.FirstOrDefault(parameter => parameter.Type?.ToString() != "EventContext"); + if (eventParameter is null) + { + return parameterList; + } + + var eventContextParameter = SyntaxFactory.Parameter(SyntaxFactory.Identifier(parameterName)) + .WithType(SyntaxFactory.ParseTypeName("EventContext")); + + return parameterList.WithParameters(parameterList.Parameters.Insert(1, eventContextParameter)); + } + + MethodDeclarationSyntax? GetMethodDeclaration(SyntaxNode root, Diagnostic diagnostic) + { + var diagnosticSpan = diagnostic.Location.SourceSpan; + var methodDeclaration = root.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf() + .OfType().First(); + return methodDeclaration; + } + + static CompilationUnitSyntax EnsureNamespaceImported(CompilationUnitSyntax root, string namespaceToInclude) + { + var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceToInclude)); + var existingUsings = root.Usings; + + if (existingUsings.Any(u => u.Name?.ToFullString() == namespaceToInclude)) + { + // Namespace is already imported. + return root; + } + + var lineEndingTrivia = root.DescendantTrivia().First(it => it.IsKind(SyntaxKind.EndOfLineTrivia)); + usingDirective = usingDirective.WithTrailingTrivia(lineEndingTrivia); + + return root.WithUsings(existingUsings.Add(usingDirective)); + } +} diff --git a/Source/Analyzers/DescriptorRules.cs b/Source/Analyzers/DescriptorRules.cs index 9df81b60..7a28d96d 100644 --- a/Source/Analyzers/DescriptorRules.cs +++ b/Source/Analyzers/DescriptorRules.cs @@ -140,7 +140,17 @@ internal static class Aggregate DiagnosticCategories.Sdk, DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "Change the On-method to take a single event as a parameter"); + description: "Change the On-method to take a single event as a parameter."); + + internal static readonly DiagnosticDescriptor MutationsCannotUseCurrentTime = + new( + DiagnosticIds.AggregateMutationsCannotUseCurrentTime, + title: "On-methods must only use data from the event", + messageFormat: "'{0}' is invalid, On-methods must only use data from the event", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "If you need to capture the time of the event in the aggregate state, you must include it in the event."); internal static readonly DiagnosticDescriptor MutationsCannotProduceEvents = new( DiagnosticIds.AggregateMutationsCannotProduceEvents, @@ -224,6 +234,16 @@ internal static class Projection DiagnosticSeverity.Error, isEnabledByDefault: true, description: "The event type is already handled by another On-method."); + + internal static readonly DiagnosticDescriptor MutationUsedCurrentTime = + new( + DiagnosticIds.ProjectionMutationUsedCurrentTime, + title: "On-methods must only use data from the event or EventContext", + messageFormat: "'{0}' is invalid, On-methods must only use data from the event or EventContext", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "If you need to get the committed time of the event, use EventContext.Occurred."); } } diff --git a/Source/Analyzers/DiagnosticIds.cs b/Source/Analyzers/DiagnosticIds.cs index ba87b132..70c1b363 100644 --- a/Source/Analyzers/DiagnosticIds.cs +++ b/Source/Analyzers/DiagnosticIds.cs @@ -72,6 +72,10 @@ public static class DiagnosticIds /// public const string PublicMethodsCannotMutateAggregateState = "AGG0006"; + /// + /// Current time can not be used in an On-method, use the timestamp from the event instead. + /// + public const string AggregateMutationsCannotUseCurrentTime = "AGG0007"; public const string ProjectionMissingAttributeRuleId = "PROJ0001"; public const string ProjectionMissingBaseClassRuleId = "PROJ0002"; @@ -79,4 +83,5 @@ public static class DiagnosticIds public const string ProjectionInvalidOnMethodReturnTypeRuleId = "PROJ0004"; public const string ProjectionInvalidOnMethodVisibilityRuleId = "PROJ0005"; public const string ProjectionDuplicateEventHandler = "PROJ0006"; + public const string ProjectionMutationUsedCurrentTime = "PROJ0007"; } diff --git a/Source/Analyzers/ProjectionsAnalyzer.cs b/Source/Analyzers/ProjectionsAnalyzer.cs index a1cd50c1..0d3e5dc6 100644 --- a/Source/Analyzers/ProjectionsAnalyzer.cs +++ b/Source/Analyzers/ProjectionsAnalyzer.cs @@ -33,7 +33,8 @@ public class ProjectionsAnalyzer : DiagnosticAnalyzer DescriptorRules.Projection.InvalidOnMethodParameters, DescriptorRules.Projection.InvalidOnMethodReturnType, DescriptorRules.Projection.InvalidOnMethodVisibility, - DescriptorRules.Projection.EventTypeAlreadyHandled + DescriptorRules.Projection.EventTypeAlreadyHandled, + DescriptorRules.Projection.MutationUsedCurrentTime ); /// @@ -61,7 +62,7 @@ static void AnalyzeProjections(SyntaxNodeAnalysisContext context) static void CheckOnMethods(SyntaxNodeAnalysisContext context, INamedTypeSymbol projectionType) { var members = projectionType.GetMembers(); - var onMethods = members.Where(_ => _.Name.Equals("On")).OfType().ToArray(); + var onMethods = members.Where(method => method.Name.Equals("On")).OfType().ToArray(); var eventTypesHandled = new HashSet(SymbolEqualityComparer.Default); foreach (var onMethod in onMethods) @@ -117,6 +118,7 @@ static void CheckOnMethods(SyntaxNodeAnalysisContext context, INamedTypeSymbol p } CheckOnReturnType(context, projectionType, onMethod, syntax); + EnsureMutationDoesNotAccessCurrentTime(context, syntax); } } @@ -156,6 +158,39 @@ static void CheckOnReturnType(SyntaxNodeAnalysisContext context, INamedTypeSymbo } } + /// + /// Checks if the method gets the current time via DateTime or DateTimeOffset + /// Since this is not allowed for the mutations, we need to report a diagnostic + /// + /// + /// + static void EnsureMutationDoesNotAccessCurrentTime(SyntaxNodeAnalysisContext context, MethodDeclarationSyntax onMethod) + { + foreach (var memberAccess in onMethod.DescendantNodes().OfType()) + { + var property = memberAccess.Name.Identifier.Text; + if (property != "Now" && property != "UtcNow") + { + continue; + } + + var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression); + // Check if the type is DateTime or DateTimeOffset + var qualifiedType = typeInfo.Type?.ToDisplayString(); + if (qualifiedType is "System.DateTime" or "System.DateTimeOffset") + { + var properties = ImmutableDictionary.Create() + .Add("expression", qualifiedType + "." + property); + context.ReportDiagnostic(Diagnostic.Create( + DescriptorRules.Projection.MutationUsedCurrentTime, + memberAccess.GetLocation(), + properties: properties, + new[] { memberAccess.ToFullString() } + )); + } + } + } + static void CheckProjectionAttributePresent(SyntaxNodeAnalysisContext context, INamedTypeSymbol projectionClass) { var hasAttribute = projectionClass.GetAttributes() diff --git a/Source/Analyzers/RoslynExtensions.cs b/Source/Analyzers/RoslynExtensions.cs new file mode 100644 index 00000000..78332a3a --- /dev/null +++ b/Source/Analyzers/RoslynExtensions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Dolittle. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dolittle.SDK.Analyzers; + +static class RoslynExtensions +{ + public static MemberAccessExpressionSyntax[] GetAccessesOfCurrentTime(this SyntaxNode scope, + SyntaxNodeAnalysisContext context) + { + var currentTimeInvocations = scope.DescendantNodes() + .OfType() + .Where(memberAccess => + { + var now = memberAccess.Name + is IdentifierNameSyntax { Identifier.Text: "Now" } + or IdentifierNameSyntax { Identifier.Text: "UtcNow" }; + if (!now) + { + return false; + } + + var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression); + // Check if the type is DateTime or DateTimeOffset + return typeInfo.Type?.ToDisplayString() == "System.DateTime" || + typeInfo.Type?.ToDisplayString() == "System.DateTimeOffset"; + }).ToArray(); + + return currentTimeInvocations; + } +} diff --git a/Tests/Analyzers/Analyzers.csproj b/Tests/Analyzers/Analyzers.csproj index 1841676c..be0c6267 100644 --- a/Tests/Analyzers/Analyzers.csproj +++ b/Tests/Analyzers/Analyzers.csproj @@ -7,7 +7,7 @@ - + diff --git a/Tests/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProviderTests.cs b/Tests/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProviderTests.cs new file mode 100644 index 00000000..b70172f5 --- /dev/null +++ b/Tests/Analyzers/CodeFixes/ProjectionMutationEventTimeCodeFixProviderTests.cs @@ -0,0 +1,153 @@ +// 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.CodeFixes; + +public class ProjectionMutationEventTimeCodeFixProviderTests : CodeFixProviderTests +{ + [Fact] + public async Task ShouldFixDateTimeNow() + { + var test = @" +using System; +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +public record NameUpdated(string Name); + +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection: ReadModel +{ + public string Name { get; set; } + public DateTime UpdatedAt { get; set; } + + public void On(NameUpdated evt) { + Name = evt.Name; + UpdatedAt = DateTime.Now; + } +}"; + + var expected = @"using System; +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +public record NameUpdated(string Name); +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection : ReadModel +{ + public string Name { get; set; } + public DateTime UpdatedAt { get; set; } + + public void On(NameUpdated evt, EventContext ctx) + { + Name = evt.Name; + UpdatedAt = ctx.Occurred.DateTime; + } +}"; + var diagnosticResult = Diagnostic(DescriptorRules.Projection.MutationUsedCurrentTime) + .WithSpan(17, 21, 17, 33) + .WithArguments("DateTime.Now"); + + await VerifyCodeFixAsync(test, expected, diagnosticResult); + } + + [Fact] + public async Task ShouldFixDateTimeOffsetNow() + { + var test = @" +using System; +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +public record NameUpdated(string Name); + +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection: ReadModel +{ + public string Name { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public void On(NameUpdated evt) { + Name = evt.Name; + UpdatedAt = DateTimeOffset.UtcNow; + } +}"; + + var expected = @"using System; +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +public record NameUpdated(string Name); +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection : ReadModel +{ + public string Name { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public void On(NameUpdated evt, EventContext ctx) + { + Name = evt.Name; + UpdatedAt = ctx.Occurred; + } +}"; + var diagnosticResult = Diagnostic(DescriptorRules.Projection.MutationUsedCurrentTime) + .WithSpan(17, 21, 17, 42) + .WithArguments("DateTimeOffset.UtcNow"); + + await VerifyCodeFixAsync(test, expected, diagnosticResult); + } + + [Fact] + public async Task ShouldFixDateTimeOffsetNowWithPreExistingEventContext() + { + var test = @" +using System; +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +public record NameUpdated(string Name); + +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection: ReadModel +{ + public string Name { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public void On(NameUpdated evt, EventContext context) { + Name = evt.Name; + UpdatedAt = DateTimeOffset.UtcNow; + } +}"; + + var expected = @"using System; +using Dolittle.SDK.Projections; +using Dolittle.SDK.Events; + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +public record NameUpdated(string Name); +[Projection(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeProjection : ReadModel +{ + public string Name { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public void On(NameUpdated evt, EventContext context) + { + Name = evt.Name; + UpdatedAt = context.Occurred; + } +}"; + var diagnosticResult = Diagnostic(DescriptorRules.Projection.MutationUsedCurrentTime) + .WithSpan(17, 21, 17, 42) + .WithArguments("DateTimeOffset.UtcNow"); + + await VerifyCodeFixAsync(test, expected, diagnosticResult); + } +} diff --git a/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs b/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs index f0619389..5fa3f303 100644 --- a/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs +++ b/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs @@ -691,5 +691,131 @@ private void On(NameUpdated @event) await VerifyAnalyzerAsync(test, expected); } + + [Fact] + public async Task ShouldFindDateTimeUsage() + { + var test = @" +using System; +using Dolittle.SDK.Aggregates; +using Dolittle.SDK.Events; + + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +record NameUpdated(string Name); + +[AggregateRoot(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeAggregate: AggregateRoot +{ + public SomeAggregate(){ + Name = ""John Doe""; + } + + string Name {get; set;} + DateTime Timestamp { get; set; } + + public void UpdateName(string name) + { + Apply(new NameUpdated(name)); + } + + private void On(NameUpdated @event) + { + Name = @event.Name; + Timestamp = DateTime.UtcNow; + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Aggregate.MutationsCannotUseCurrentTime) + .WithSpan(28, 21, 28, 36) + .WithArguments("DateTime.UtcNow") + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldFindDateTimeUsageWhenQualified() + { + var test = @" +using Dolittle.SDK.Aggregates; +using Dolittle.SDK.Events; + + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +record NameUpdated(string Name); + +[AggregateRoot(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeAggregate: AggregateRoot +{ + public SomeAggregate(){ + Name = ""John Doe""; + } + + string Name {get; set;} + System.DateTime Timestamp { get; set; } + + public void UpdateName(string name) + { + Apply(new NameUpdated(name)); + } + + private void On(NameUpdated @event) + { + Name = @event.Name; + Timestamp = System.DateTime.UtcNow; + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Aggregate.MutationsCannotUseCurrentTime) + .WithSpan(27, 21, 27, 43) + .WithArguments("System.DateTime.UtcNow") + }; + + await VerifyAnalyzerAsync(test, expected); + } + [Fact] + public async Task ShouldFindDateTimeOffsetUsageWhenQualified() + { + var test = @" +using Dolittle.SDK.Aggregates; +using Dolittle.SDK.Events; + + +[EventType(""5dc02e84-c6fc-4e1b-997c-ec33d0048a3b"")] +record NameUpdated(string Name); + +[AggregateRoot(""10ef9f40-3e61-444a-9601-f521be2d547e"")] +class SomeAggregate: AggregateRoot +{ + public SomeAggregate(){ + Name = ""John Doe""; + } + + string Name {get; set;} + System.DateTimeOffset Timestamp { get; set; } + + public void UpdateName(string name) + { + Apply(new NameUpdated(name)); + } + + private void On(NameUpdated @event) + { + Name = @event.Name; + Timestamp = System.DateTimeOffset.UtcNow; + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Aggregate.MutationsCannotUseCurrentTime) + .WithSpan(27, 21, 27, 49) + .WithArguments("System.DateTimeOffset.UtcNow") + }; + + await VerifyAnalyzerAsync(test, expected); + } } diff --git a/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs b/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs index 4095d17e..e5689227 100644 --- a/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs +++ b/Tests/Analyzers/Diagnostics/ProjectionAnalyzerTests.cs @@ -309,4 +309,73 @@ public void On(NameUpdated evt) await VerifyAnalyzerAsync(test, expected); } + + [Fact] + public async Task ShouldFindDateTimeNow() + { + var test = @" +using System; +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: ReadModel +{ + public string Name {get; set;} + public DateTime LastUpdated {get; set;} + + public void On(NameUpdated evt) + { + Name = evt.Name; + LastUpdated = DateTime.Now; + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Projection.MutationUsedCurrentTime) + .WithSpan(19, 23, 19, 35) + .WithArguments("DateTime.Now") + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldFindDateTimeOffsetNow() + { + var test = @" +using System; +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: ReadModel +{ + public string Name {get; set;} + public DateTimeOffset LastUpdated {get; set;} + + public void On(NameUpdated evt) + { + Name = evt.Name; + LastUpdated = DateTimeOffset.UtcNow; + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Projection.MutationUsedCurrentTime) + .WithSpan(19, 23, 19, 44) + .WithArguments("DateTimeOffset.UtcNow") + }; + + await VerifyAnalyzerAsync(test, expected); + } + } diff --git a/Tests/ProjectionsTests/Occurred/SalesPerDay.cs b/Tests/ProjectionsTests/Occurred/SalesPerDay.cs index 639ae7aa..2a013397 100644 --- a/Tests/ProjectionsTests/Occurred/SalesPerDay.cs +++ b/Tests/ProjectionsTests/Occurred/SalesPerDay.cs @@ -32,7 +32,7 @@ public void On(ProductSold evt, EventContext ctx) [Projection("3dce944f-279e-4150-bef3-dd9e113220c6")] public class SalesPerDayTotalByEventSource : ReadModel { - public string Store { get; private set; } + public string Store { get; private set; } = null!; public DateOnly Date { get; private set; } public decimal TotalSales { get; private set; } @@ -58,7 +58,7 @@ class ProductKeySelector: IKeySelector [Projection("3dce944f-279e-4150-bef3-dd9e113220c6")] public class SalesPerDayTotalByFunction : ReadModel { - public string Store { get; private set; } + public string Store { get; private set; } = null!; public DateOnly Date { get; private set; } public decimal TotalSales { get; private set; }