From 03525a9126e4e3b90d091cd9f6561621154edb1b Mon Sep 17 00:00:00 2001 From: Magne Helleborg Date: Fri, 15 Sep 2023 13:41:11 +0200 Subject: [PATCH 1/3] StartFrom / StopAt feature for event handlers --- .../Builder/EventHandlerBuilder.cs | 33 ++++++++++++++- .../Builder/IEventHandlerBuilder.cs | 24 +++++++++++ Source/Events.Handling/EventHandler.cs | 1 - .../Events.Handling/EventHandlerAttribute.cs | 41 +++++++++++-------- Source/Events.Handling/EventHandlerModelId.cs | 4 +- .../Internal/EventHandlerProcessor.cs | 6 ++- Source/Events.Handling/ProcessFrom.cs | 4 +- .../for_EventHandler/when_creating.cs | 4 +- .../when_handling/given/an_event_handler.cs | 4 +- 9 files changed, 94 insertions(+), 27 deletions(-) diff --git a/Source/Events.Handling/Builder/EventHandlerBuilder.cs b/Source/Events.Handling/Builder/EventHandlerBuilder.cs index 4bc607c3..2e9e8975 100644 --- a/Source/Events.Handling/Builder/EventHandlerBuilder.cs +++ b/Source/Events.Handling/Builder/EventHandlerBuilder.cs @@ -21,6 +21,9 @@ public class EventHandlerBuilder : IEventHandlerBuilder, ICanTryBuildEventHandle bool _partitioned = true; int _concurrency = 1; ScopeId _scopeId = ScopeId.Default; + ProcessFrom _resetTo; + DateTimeOffset? _startFrom; + DateTimeOffset? _stopAt; EventHandlerAlias? _alias; /// @@ -39,7 +42,7 @@ public EventHandlerBuilder(EventHandlerId eventHandlerId, IModelBuilder modelBui /// /// Gets the . /// - public EventHandlerModelId ModelId => new(_eventHandlerId, _partitioned, _scopeId, alias: _alias?.Value, concurrency: _concurrency); + public EventHandlerModelId ModelId => new(_eventHandlerId, _partitioned, _scopeId, alias: _alias?.Value, concurrency: _concurrency,_resetTo, _startFrom, _stopAt); /// public IEventHandlerMethodsBuilder Partitioned() @@ -87,6 +90,34 @@ public IEventHandlerBuilder WithAlias(EventHandlerAlias alias) return this; } + /// + public IEventHandlerBuilder StartFrom(ProcessFrom processFrom) + { + Unbind(); + _resetTo = processFrom; + Bind(); + return this; + } + + /// + public IEventHandlerBuilder StartFrom(DateTimeOffset processFrom) + { + Unbind(); + _startFrom = processFrom; + Bind(); + return this; + } + + /// + public IEventHandlerBuilder StopAt(DateTimeOffset stopAt) + { + Unbind(); + _stopAt = stopAt; + Bind(); + return this; + } + + /// public bool TryBuild(EventHandlerModelId identifier, IEventTypes eventTypes, IClientBuildResults buildResults, out IEventHandler eventHandler) { diff --git a/Source/Events.Handling/Builder/IEventHandlerBuilder.cs b/Source/Events.Handling/Builder/IEventHandlerBuilder.cs index bab07aee..5077c9de 100644 --- a/Source/Events.Handling/Builder/IEventHandlerBuilder.cs +++ b/Source/Events.Handling/Builder/IEventHandlerBuilder.cs @@ -1,6 +1,7 @@ // 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 Dolittle.SDK.Events.Handling.Builder.Methods; namespace Dolittle.SDK.Events.Handling.Builder; @@ -41,4 +42,27 @@ public interface IEventHandlerBuilder /// The . /// The builder for continuation. IEventHandlerBuilder WithAlias(EventHandlerAlias alias); + + /// + /// Set where in the stream the event handler starts if it has no state + /// + /// + /// + IEventHandlerBuilder StartFrom(ProcessFrom processFrom); + + /// + /// Set when in the stream the event handler starts if it has no state. + /// Overrides if set + /// + /// Timestamp to start processing from + /// + IEventHandlerBuilder StartFrom(DateTimeOffset processFrom); + + /// + /// Optional + /// If the event handler should not process newer events than the given timestamp, this can be set. + /// + /// + /// + IEventHandlerBuilder StopAt(DateTimeOffset stopAt); } diff --git a/Source/Events.Handling/EventHandler.cs b/Source/Events.Handling/EventHandler.cs index 094d3449..cffa01fa 100644 --- a/Source/Events.Handling/EventHandler.cs +++ b/Source/Events.Handling/EventHandler.cs @@ -8,7 +8,6 @@ using System.Threading; using System.Threading.Tasks; using Diagnostics; -using Dolittle.Runtime.Events.Processing.Contracts; using Dolittle.SDK.Diagnostics.OpenTelemetry; using Dolittle.SDK.Events.Handling.Builder.Methods; using Dolittle.SDK.Execution; diff --git a/Source/Events.Handling/EventHandlerAttribute.cs b/Source/Events.Handling/EventHandlerAttribute.cs index 3c55668f..2aed8fa5 100644 --- a/Source/Events.Handling/EventHandlerAttribute.cs +++ b/Source/Events.Handling/EventHandlerAttribute.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; +using System.Globalization; using Dolittle.SDK.Common.Model; namespace Dolittle.SDK.Events.Handling; @@ -23,35 +24,39 @@ public class EventHandlerAttribute : Attribute, IDecoratedTypeDecoratorThe scope that the event handler handles events in. /// The alias for the event handler. /// How many events can be processed simultaneously - // /// Where to start processing if the event handler does not have state. Defaults to the first event in the log. - // /// Determines a specific event timestamp to start at if set. Overrides resetTo when used. - // /// Determines a specific event timestamp to stop processing at if set. + /// Where in the event log to start processing if the event handler has not been used before. Defaults to the first event. + /// Determines a specific event timestamp to start at if set. Overrides startFrom when used. Format is ISO 8601 + /// Determines a specific event timestamp to stop processing at if set. Format is ISO 8601 public EventHandlerAttribute( string eventHandlerId, bool partitioned = true, string? inScope = null, string? alias = null, - int concurrency = 1 - // , - // ProcessFrom resetTo = ProcessFrom.First, - // string? startFrom = null, - // string? stopAt = null - ) + int concurrency = 1, + ProcessFrom startFrom = ProcessFrom.Earliest, + string? startFromTimestamp = null, + string? stopAtTimestamp = null + ) { _eventHandlerId = eventHandlerId; _alias = alias; Concurrency = concurrency; - // ResetTo = resetTo; - // StartFrom = startFrom; - // StopAt = stopAt; + StartFrom = startFrom; + StartFromTimestamp = string.IsNullOrWhiteSpace(startFromTimestamp) ? null : DateTimeOffset.Parse(startFromTimestamp, CultureInfo.InvariantCulture); + StopAtTimestamp = string.IsNullOrWhiteSpace(stopAtTimestamp) ? null : DateTimeOffset.Parse(stopAtTimestamp, CultureInfo.InvariantCulture); + if (startFromTimestamp is not null && stopAtTimestamp is not null && StartFromTimestamp >= StopAtTimestamp) + { + throw new ArgumentException("StartFromTimestamp must be before StopAtTimestamp"); + } + Partitioned = partitioned; Scope = inScope ?? ScopeId.Default; } public int Concurrency { get; set; } - // public ProcessFrom ResetTo { get; } - // public string? StartFrom { get; } - // public string? StopAt { get; } + public ProcessFrom StartFrom { get; } + public DateTimeOffset? StartFromTimestamp { get; } + public DateTimeOffset? StopAtTimestamp { get; } /// /// Gets a value indicating whether this event handler is partitioned. @@ -67,8 +72,8 @@ public EventHandlerAttribute( /// public EventHandlerModelId GetIdentifier(Type decoratedType) { - DateTimeOffset? startFrom = null; - DateTimeOffset? stopAt = null; + // DateTimeOffset? startFrom = null; + // DateTimeOffset? stopAt = null; // if (!string.IsNullOrWhiteSpace(StartFrom)) // { // startFrom = DateTimeOffset.Parse(StartFrom, DateTimeFormatInfo.InvariantInfo); @@ -78,6 +83,6 @@ public EventHandlerModelId GetIdentifier(Type decoratedType) // stopAt = DateTimeOffset.Parse(StopAt, DateTimeFormatInfo.InvariantInfo); // } - return new(_eventHandlerId, Partitioned, Scope, _alias ?? decoratedType.Name, Concurrency, ProcessFrom.First, startFrom, stopAt); + return new(_eventHandlerId, Partitioned, Scope, _alias ?? decoratedType.Name, Concurrency, StartFrom, StartFromTimestamp, StopAtTimestamp); } } diff --git a/Source/Events.Handling/EventHandlerModelId.cs b/Source/Events.Handling/EventHandlerModelId.cs index 19eb12f0..e8ffcf2a 100644 --- a/Source/Events.Handling/EventHandlerModelId.cs +++ b/Source/Events.Handling/EventHandlerModelId.cs @@ -27,8 +27,8 @@ public EventHandlerModelId( bool partitioned, ScopeId scope, string? alias, - int concurrency = 1, - ProcessFrom resetTo = ProcessFrom.First, + int concurrency, + ProcessFrom resetTo, DateTimeOffset? startFrom = null, DateTimeOffset? stopAt = null) : base("EventHandler", id, alias, scope) diff --git a/Source/Events.Handling/Internal/EventHandlerProcessor.cs b/Source/Events.Handling/Internal/EventHandlerProcessor.cs index 3b0ac7c1..4dcf490c 100644 --- a/Source/Events.Handling/Internal/EventHandlerProcessor.cs +++ b/Source/Events.Handling/Internal/EventHandlerProcessor.cs @@ -58,6 +58,10 @@ public override EventHandlerRegistrationRequest RegistrationRequest registrationRequest.Alias = _eventHandler.Alias!.Value; } + if (_eventHandler.StopAt.HasValue) + { + registrationRequest.StopAt = Timestamp.FromDateTimeOffset(_eventHandler.StopAt.Value); + } registrationRequest.EventTypes.AddRange(_eventHandler.HandledEvents.Select(_ => _.ToProtobuf()).ToArray()); return registrationRequest; } @@ -75,7 +79,7 @@ static StartFrom GetStartFrom(IEventHandler eventHandler) return new StartFrom { - Position = eventHandler.ResetTo == ProcessFrom.Last ? StartFrom.Types.Position.Latest : StartFrom.Types.Position.Start + Position = eventHandler.ResetTo == ProcessFrom.Latest ? StartFrom.Types.Position.Latest : StartFrom.Types.Position.Start }; } diff --git a/Source/Events.Handling/ProcessFrom.cs b/Source/Events.Handling/ProcessFrom.cs index 7bbfeccf..9efd5df8 100644 --- a/Source/Events.Handling/ProcessFrom.cs +++ b/Source/Events.Handling/ProcessFrom.cs @@ -11,9 +11,9 @@ public enum ProcessFrom /// /// Start from the beginning of the stream, process all events. /// - First, + Earliest, /// /// Start after the latest event in the stream, only process new events. /// - Last + Latest } diff --git a/Specifications/Events.Handling/for_EventHandler/when_creating.cs b/Specifications/Events.Handling/for_EventHandler/when_creating.cs index 1958ad9f..c42f6aa5 100644 --- a/Specifications/Events.Handling/for_EventHandler/when_creating.cs +++ b/Specifications/Events.Handling/for_EventHandler/when_creating.cs @@ -18,6 +18,8 @@ public class when_creating static IEnumerable event_types; static IDictionary event_handler_methods; static EventHandler event_handler; + static int concurrency = 1; + static ProcessFrom reset_to = ProcessFrom.Earliest; Establish context = () => { @@ -36,7 +38,7 @@ public class when_creating _ => new EventHandlerMethod((@event, eventContext) => Task.CompletedTask) as IEventHandlerMethod)); }; - Because of = () => event_handler = new EventHandler(new EventHandlerModelId(identifier, partitioned, scope_id, alias), event_handler_methods); + Because of = () => event_handler = new EventHandler(new EventHandlerModelId(identifier, partitioned, scope_id, alias, concurrency, reset_to), event_handler_methods); It should_not_be_null = () => event_handler.ShouldNotBeNull(); It should_have_the_correct_partitioned_value = () => event_handler.Partitioned.ShouldEqual(partitioned); diff --git a/Specifications/Events.Handling/for_EventHandler/when_handling/given/an_event_handler.cs b/Specifications/Events.Handling/for_EventHandler/when_handling/given/an_event_handler.cs index 7909553b..2dcc3976 100644 --- a/Specifications/Events.Handling/for_EventHandler/when_handling/given/an_event_handler.cs +++ b/Specifications/Events.Handling/for_EventHandler/when_handling/given/an_event_handler.cs @@ -17,6 +17,8 @@ public class an_event_handler protected static Mock event_handler_method; protected static IDictionary event_handler_methods; protected static EventHandler event_handler; + protected static int concurrency = 1; + protected static ProcessFrom reset_to = ProcessFrom.Earliest; Establish context = () => { @@ -29,6 +31,6 @@ public class an_event_handler { { handled_event_type, event_handler_method.Object } }; - event_handler = new EventHandler(new EventHandlerModelId(identifier, partitioned, scope_id, "some alias"), event_handler_methods); + event_handler = new EventHandler(new EventHandlerModelId(identifier, partitioned, scope_id, "some alias", concurrency, reset_to, null, null), event_handler_methods); }; } \ No newline at end of file From 6d272bcb3d3c28a377b9297e1a1547edbed81cc1 Mon Sep 17 00:00:00 2001 From: Magne Helleborg Date: Fri, 15 Sep 2023 13:43:23 +0200 Subject: [PATCH 2/3] Mutation analyzer for public aggregate methods. Checks for incorrect mutations outside of using events --- Source/Analyzers/AggregateAnalyzer.cs | 70 ++++++- Source/Analyzers/DescriptorRules.cs | 9 + Source/Analyzers/DiagnosticIds.cs | 6 + .../Diagnostics/AggregateAnalyzerTests.cs | 172 +++++++++++++++++- 4 files changed, 244 insertions(+), 13 deletions(-) diff --git a/Source/Analyzers/AggregateAnalyzer.cs b/Source/Analyzers/AggregateAnalyzer.cs index 7d086eee..b2f5275f 100644 --- a/Source/Analyzers/AggregateAnalyzer.cs +++ b/Source/Analyzers/AggregateAnalyzer.cs @@ -28,7 +28,8 @@ public class AggregateAnalyzer : DiagnosticAnalyzer DescriptorRules.Aggregate.MutationShouldBePrivate, DescriptorRules.Aggregate.MutationHasIncorrectNumberOfParameters, DescriptorRules.Aggregate.MutationsCannotProduceEvents, - DescriptorRules.Events.MissingAttribute + DescriptorRules.Events.MissingAttribute, + DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState ); /// @@ -53,7 +54,8 @@ static void AnalyzeAggregates(SyntaxNodeAnalysisContext context) var handledEvents = CheckOnMethods(context, aggregateSymbol); CheckApplyInvocations(context, aggregateSyntax, handledEvents); - CheckApplyInvocationsInOnMethods(context, aggregateSyntax, aggregateSymbol); + CheckApplyInvocationsInOnMethods(context, aggregateSymbol); + CheckMutationsInPublicMethods(context, aggregateSymbol); } @@ -144,10 +146,8 @@ static void CheckApplyInvocations(SyntaxNodeAnalysisContext context, ClassDeclar } } - static void CheckApplyInvocationsInOnMethods(SyntaxNodeAnalysisContext context, ClassDeclarationSyntax aggregateClassSyntax, INamedTypeSymbol aggregateType) + static void CheckApplyInvocationsInOnMethods(SyntaxNodeAnalysisContext context, INamedTypeSymbol aggregateType) { - var semanticModel = context.SemanticModel; - var onMethods = aggregateType .GetMembers() .Where(member => member.Name.Equals("On")) @@ -194,4 +194,62 @@ static void CheckApplyInvocationsInOnMethods(SyntaxNodeAnalysisContext context, } } } -} + + static void CheckMutationsInPublicMethods(SyntaxNodeAnalysisContext context, INamedTypeSymbol aggregateType) + { + var publicMethods = aggregateType + .GetMembers() + .Where(member => !member.Name.Equals("On")) + .OfType() + .Where(method => method.DeclaredAccessibility.HasFlag(Accessibility.Public)) + .ToArray(); + if (publicMethods.Length == 0) + { + return; + } + var walker = new MutationWalker(context, aggregateType); + + foreach (var onMethod in publicMethods) + { + if (onMethod.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() is not MethodDeclarationSyntax syntax) + { + continue; + } + walker.Visit(syntax); + } + } + + class MutationWalker : CSharpSyntaxWalker + { + readonly SyntaxNodeAnalysisContext _context; + readonly INamedTypeSymbol _aggregateType; + + public MutationWalker(SyntaxNodeAnalysisContext context, INamedTypeSymbol aggregateType) + { + _context = context; + _aggregateType = aggregateType; + } + + public override void VisitAssignmentExpression(AssignmentExpressionSyntax node) + { + var leftExpression = node.Left; + + if (leftExpression is IdentifierNameSyntax || leftExpression is MemberAccessExpressionSyntax) + { + var symbolInfo = _context.SemanticModel.GetSymbolInfo(leftExpression); + if (symbolInfo.Symbol is IFieldSymbol || symbolInfo.Symbol is IPropertySymbol) + { + var containingType = symbolInfo.Symbol.ContainingType; + if (containingType != null && SymbolEqualityComparer.Default.Equals(_aggregateType, containingType)) + { + var diagnostic = Diagnostic.Create(DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState, leftExpression.GetLocation()); + _context.ReportDiagnostic(diagnostic); + } + } + } + + base.VisitAssignmentExpression(node); + } + + // You can also add other types of mutations like increments, decrements, method calls etc. + }} diff --git a/Source/Analyzers/DescriptorRules.cs b/Source/Analyzers/DescriptorRules.cs index 6c9b880e..aaa09e0c 100644 --- a/Source/Analyzers/DescriptorRules.cs +++ b/Source/Analyzers/DescriptorRules.cs @@ -90,5 +90,14 @@ internal static class Aggregate DiagnosticSeverity.Error, isEnabledByDefault: true ); + + internal static readonly DiagnosticDescriptor PublicMethodsCannotMutateAggregateState = new( + DiagnosticIds.PublicMethodsCannotMutateAggregateState, + "Aggregates should only be mutated with events", + "Public methods can not mutate the state of an aggregate. All mutations needs to be done via events.", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); } } diff --git a/Source/Analyzers/DiagnosticIds.cs b/Source/Analyzers/DiagnosticIds.cs index a90d1962..e9dda2fe 100644 --- a/Source/Analyzers/DiagnosticIds.cs +++ b/Source/Analyzers/DiagnosticIds.cs @@ -44,4 +44,10 @@ public static class DiagnosticIds /// 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"; } diff --git a/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs b/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs index f922eb4c..f0619389 100644 --- a/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs +++ b/Tests/Analyzers/Diagnostics/AggregateAnalyzerTests.cs @@ -22,6 +22,10 @@ record NameUpdated(string Name); [AggregateRoot(""10ef9f40-3e61-444a-9601-f521be2d547e"")] class SomeAggregate: AggregateRoot { + public SomeAggregate(){ + Name = ""John Doe""; + } + public string Name {get; set;} public void UpdateName(string name) @@ -38,7 +42,7 @@ private void On(NameUpdated @event) } [Fact] - public async Task ShouldFindNonPrivateMutation() + public async Task ShouldFindNonPrivateOnMethod() { var test = @" using Dolittle.SDK.Aggregates; @@ -75,7 +79,7 @@ public void On(NameUpdated @event) } [Fact] - public async Task ShouldFindMutationWithIncorrectParameters() + public async Task ShouldFindOnMethodWithIncorrectParameters() { var test = @" using Dolittle.SDK.Aggregates; @@ -112,7 +116,7 @@ private void On(NameUpdated @event, string shouldNotBeHere) } [Fact] - public async Task ShouldFindMutationWithNoParameters() + public async Task ShouldFindOnMethodWithNoParameters() { var test = @" using Dolittle.SDK.Aggregates; @@ -225,7 +229,7 @@ private void On(NameUpdated @event) } [Fact] - public async Task ShouldFindMissingMutationFromConstructor() + public async Task ShouldFindMissingOnMethodFromConstructor() { var test = @" using Dolittle.SDK.Aggregates; @@ -256,7 +260,7 @@ public void UpdateName(string name) } [Fact] - public async Task ShouldFindMissingMutationWhenNested() + public async Task ShouldFindMissingOnMethodWhenNested() { var test = @" using Dolittle.SDK.Aggregates; @@ -290,7 +294,7 @@ public void UpdateName(string name) } [Fact] - public async Task ShouldFindMissingMutation() + public async Task ShouldFindMissingOnMethod() { var test = @" using Dolittle.SDK.Aggregates; @@ -328,7 +332,7 @@ public void Rename(string name) } [Fact] - public async Task ShouldFindMissingMutationFromVariable() + public async Task ShouldFindMissingOnMethodFromVariable() { var test = @" using Dolittle.SDK.Aggregates; @@ -534,4 +538,158 @@ void On(NameUpdated @event) await VerifyAnalyzerFindsNothingAsync(test); } + [Fact] + public async Task ShouldFindInvalidMutationOnProperty() + { + 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 string Name {get; set;} + + public void UpdateName(string name) + { + Apply(new NameUpdated(name)); + Name = name; + } + + private void On(NameUpdated @event) + { + Name = @event.Name; + } +}"; + + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState) + .WithSpan(17, 9, 17, 13) + .WithArguments("Name = name;") + }; + + await VerifyAnalyzerAsync(test, expected); + } + + + [Fact] + public async Task ShouldFindInvalidMutationOnThisProperty() + { + 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 string Name {get; set;} + + public void UpdateName(string name) + { + Apply(new NameUpdated(name)); + this.Name = name; + } + + private void On(NameUpdated @event) + { + Name = @event.Name; + } +}"; + + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState) + .WithSpan(17, 9, 17, 18) + .WithArguments("this.Name = name;") + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldFindInvalidMutationOnField() + { + 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 +{ + string? _name; + + public void UpdateName(string name) + { + Apply(new NameUpdated(name)); + _name = name; + } + + private void On(NameUpdated @event) + { + _name = @event.Name; + } +}"; + + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState) + .WithSpan(17, 9, 17, 14) + .WithArguments("_name = name;") + }; + + await VerifyAnalyzerAsync(test, expected); + } + + + [Fact] + public async Task ShouldFindInvalidMutationOnThisField() + { + 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 +{ + string? _name; + + public void UpdateName(string name) + { + Apply(new NameUpdated(name)); + this._name = name; + } + + private void On(NameUpdated @event) + { + this._name = @event.Name; + } +}"; + + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Aggregate.PublicMethodsCannotMutateAggregateState) + .WithSpan(17, 9, 17, 19) + .WithArguments("this._name = name;") + }; + + await VerifyAnalyzerAsync(test, expected); + } + } From c55b708d72a78c066a826013cc23e32df24409b1 Mon Sep 17 00:00:00 2001 From: Magne Helleborg Date: Mon, 18 Sep 2023 14:07:25 +0200 Subject: [PATCH 3/3] EventHandler analyzers. Includes checks for visibility, date parsing, date logic & EventContext fix for Handlers --- Source/Analyzers/AnalysisExtensions.cs | 1 + ...EventHandlerEventContextCodeFixProvider.cs | 119 ++++++++ Source/Analyzers/DescriptorRules.cs | 42 ++- Source/Analyzers/DiagnosticIds.cs | 16 ++ Source/Analyzers/DolittleTypes.cs | 2 + Source/Analyzers/EventHandlerAnalyzer.cs | 157 ++++++++++ Tests/Analyzers/CodeFixProviderTests.cs | 8 + ...HandlerEventContextCodeFixProviderTests.cs | 89 ++++++ .../Diagnostics/AnnotationAnalyzerTests.cs | 19 ++ .../Diagnostics/EventHandlerAnalyzerTests.cs | 269 ++++++++++++++++++ 10 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs create mode 100644 Source/Analyzers/EventHandlerAnalyzer.cs create mode 100644 Tests/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProviderTests.cs create mode 100644 Tests/Analyzers/Diagnostics/EventHandlerAnalyzerTests.cs diff --git a/Source/Analyzers/AnalysisExtensions.cs b/Source/Analyzers/AnalysisExtensions.cs index 179744df..a62d8e59 100644 --- a/Source/Analyzers/AnalysisExtensions.cs +++ b/Source/Analyzers/AnalysisExtensions.cs @@ -125,4 +125,5 @@ static bool MatchesName(this AttributeArgumentSyntax attributeArgument, string p { return attributeArgument.NameColon?.Name.Identifier.Text == parameterName; } + } diff --git a/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs b/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs new file mode 100644 index 00000000..9a428e8f --- /dev/null +++ b/Source/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProvider.cs @@ -0,0 +1,119 @@ +// 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; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AttributeMissingCodeFixProvider)), Shared] +public class EventHandlerEventContextCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create( + DiagnosticIds.EventHandlerMissingEventContext + ); + + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + var document = context.Document; + + foreach (var diagnostic in context.Diagnostics) + { + switch (diagnostic.Id) + { + case DiagnosticIds.EventHandlerMissingEventContext: + context.RegisterCodeFix( + CodeAction.Create( + "Add EventContext parameter", + ct => AddEventContextParameter(context, diagnostic, document, ct), + nameof(EventHandlerEventContextCodeFixProvider) + ".AddEventContext"), + diagnostic); + break; + } + } + + + return Task.CompletedTask; + } + + async Task AddEventContextParameter(CodeFixContext context, Diagnostic diagnostic, Document document, CancellationToken ct) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) return document; + + // Find the method declaration identified by the diagnostic. + var methodDeclaration = GetMethodDeclaration(root, diagnostic); + if (methodDeclaration is null) + { + return document; + } + + + var updatedRoot = root.ReplaceNode(methodDeclaration, WithEventContextParameter(methodDeclaration)); + var newRoot = EnsureNamespaceImported((CompilationUnitSyntax)updatedRoot, "Dolittle.SDK.Events"); + return document.WithSyntaxRoot(newRoot); + } + + /// + /// Adds EventContext parameter to the method declaration + /// + /// + /// + MethodDeclarationSyntax WithEventContextParameter(MethodDeclarationSyntax methodDeclaration) + { + var existingParameters = methodDeclaration.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 methodDeclaration; + } + + + var eventContextParameter = SyntaxFactory.Parameter(SyntaxFactory.Identifier("ctx")).WithType(SyntaxFactory.ParseTypeName("EventContext")); + + var originalParameterList = methodDeclaration.ParameterList; + var newParameterList = SyntaxFactory.ParameterList( + SyntaxFactory.SeparatedList( + new[] + { + eventParameter, + eventContextParameter + } + ) + ).WithLeadingTrivia(originalParameterList.GetLeadingTrivia()) + .WithTrailingTrivia(originalParameterList.GetTrailingTrivia()); + return methodDeclaration.WithParameterList(newParameterList); + } + + MethodDeclarationSyntax? GetMethodDeclaration(SyntaxNode root, Diagnostic diagnostic) + { + var diagnosticSpan = diagnostic.Location.SourceSpan; + var methodDeclaration = root.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().First(); + return methodDeclaration; + } + + public 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(_ => _.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 aaa09e0c..3989fcd8 100644 --- a/Source/Analyzers/DescriptorRules.cs +++ b/Source/Analyzers/DescriptorRules.cs @@ -7,6 +7,26 @@ namespace Dolittle.SDK.Analyzers; static class DescriptorRules { + internal static readonly DiagnosticDescriptor InvalidTimestamp = + new( + DiagnosticIds.InvalidTimestampParameter, + title: "Invalid DateTimeOffset format", + messageFormat: "Value '{0}' should be a valid DateTimeOffset", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The value should be a valid DateTimeOffset."); + + internal static readonly DiagnosticDescriptor InvalidStartStopTimestamp = + new( + DiagnosticIds.InvalidStartStopTime, + title: "Start is not before stop", + messageFormat: "'{0}' should be before '{1}'", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Start timestamp should be before stop timestamp."); + internal static readonly DiagnosticDescriptor InvalidIdentity = new( DiagnosticIds.AttributeInvalidIdentityRuleId, @@ -26,7 +46,17 @@ static class DescriptorRules DiagnosticSeverity.Error, isEnabledByDefault: true, description: "Assign a unique identity in the attribute"); - + + internal static readonly DiagnosticDescriptor InvalidAccessibility = + new( + DiagnosticIds.InvalidAccessibility, + title: "Invalid accessibility level", + messageFormat: "{0} needs to be '{1}'", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Change the accessibility level to '{1}'."); + internal static class Events { internal static readonly DiagnosticDescriptor MissingAttribute = @@ -38,6 +68,16 @@ internal static class Events DiagnosticSeverity.Error, isEnabledByDefault: true, description: "Mark the event with an EventTypeAttribute and assign an identifier to it"); + + internal static readonly DiagnosticDescriptor MissingEventContext = + new( + DiagnosticIds.EventHandlerMissingEventContext, + title: "Handle method does not take EventContext as the second parameter", + messageFormat: "{0} is missing EventContext argument", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Add the EventContext as the second parameter to the Handle method"); } internal static class Aggregate diff --git a/Source/Analyzers/DiagnosticIds.cs b/Source/Analyzers/DiagnosticIds.cs index e9dda2fe..8b919185 100644 --- a/Source/Analyzers/DiagnosticIds.cs +++ b/Source/Analyzers/DiagnosticIds.cs @@ -20,6 +20,20 @@ public static class DiagnosticIds /// public const string IdentityIsNotUniqueRuleId = "SDK0003"; + /// + /// 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. /// @@ -50,4 +64,6 @@ public static class DiagnosticIds /// All mutations need to be done in On-methods. /// public const string PublicMethodsCannotMutateAggregateState = "AGG0006"; + + } diff --git a/Source/Analyzers/DolittleTypes.cs b/Source/Analyzers/DolittleTypes.cs index 1ada642f..5417d5e6 100644 --- a/Source/Analyzers/DolittleTypes.cs +++ b/Source/Analyzers/DolittleTypes.cs @@ -12,5 +12,7 @@ static class DolittleTypes public const string ICommitEventsInterface = "Dolittle.SDK.Events.Store.ICommitEvents"; + public const string EventContext = "Dolittle.SDK.Events.EventContext"; + } diff --git a/Source/Analyzers/EventHandlerAnalyzer.cs b/Source/Analyzers/EventHandlerAnalyzer.cs new file mode 100644 index 00000000..4250beac --- /dev/null +++ b/Source/Analyzers/EventHandlerAnalyzer.cs @@ -0,0 +1,157 @@ +using System; +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; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class EventHandlerAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + DescriptorRules.InvalidTimestamp, + DescriptorRules.InvalidStartStopTimestamp, + DescriptorRules.InvalidAccessibility, + DescriptorRules.Events.MissingAttribute, + DescriptorRules.Events.MissingEventContext + ); + + const int StartFromTimestampOffset = 6; + const int StopAtTimestampOffset = 7; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterSyntaxNodeAction(AnalyzeEventHandler, ImmutableArray.Create(SyntaxKind.ClassDeclaration)); + } + + void AnalyzeEventHandler(SyntaxNodeAnalysisContext context) + { + var classSyntax = (ClassDeclarationSyntax)context.Node; + var classSymbol = context.SemanticModel.GetDeclaredSymbol(classSyntax); + if (classSymbol is null || !classSymbol.HasAttribute(DolittleTypes.EventHandlerAttribute)) + { + return; + } + + AnalyzeEventHandlerAttribute(context, classSymbol); + AnalyzeHandleMethods(context, classSymbol); + } + + void AnalyzeHandleMethods(SyntaxNodeAnalysisContext context, INamedTypeSymbol classSymbol) + { + var handleMethods = classSymbol.GetMembers() + .OfType() + .Where(method => method.Name == "Handle"); + + foreach (var handleMethod in handleMethods) + { + AnalyzeHandleMethod(context, handleMethod); + } + } + + void AnalyzeHandleMethod(SyntaxNodeAnalysisContext context, IMethodSymbol handleMethod) + { + // Check that it is public + if (!handleMethod.DeclaredAccessibility.HasFlag(Accessibility.Public)) + { + var diagnostic = Diagnostic.Create(DescriptorRules.InvalidAccessibility, handleMethod.Locations[0], handleMethod.Name, Accessibility.Public); + context.ReportDiagnostic(diagnostic); + } + + // Get the first parameter and get the type + var parameter = handleMethod.Parameters.FirstOrDefault(); + if (parameter is null) + { + return; + } + // Check if the type has the EventType attribute + if (!parameter.Type.HasEventTypeAttribute()) + { + var diagnostic = Diagnostic.Create(DescriptorRules.Events.MissingAttribute, parameter.Locations[0], + parameter.Type.ToTargetClassAndAttributeProps(DolittleTypes.EventTypeAttribute), parameter.Type.Name); + context.ReportDiagnostic(diagnostic); + } + + // Check that the method takes an EventContext as the second parameter + var secondParameter = handleMethod.Parameters.Skip(1).FirstOrDefault(); + if(secondParameter is null || secondParameter.Type.ToString() != DolittleTypes.EventContext) + { + var diagnostic = Diagnostic.Create(DescriptorRules.Events.MissingEventContext, handleMethod.Locations[0], handleMethod.Name); + context.ReportDiagnostic(diagnostic); + } + } + + static void AnalyzeEventHandlerAttribute(SyntaxNodeAnalysisContext context, INamedTypeSymbol classSymbol) + { + var eventHandlerAttribute = classSymbol.GetAttributes() + .Single(attribute => attribute.AttributeClass?.ToDisplayString() == DolittleTypes.EventHandlerAttribute); + if (eventHandlerAttribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken) is not AttributeSyntax attributeSyntaxNode) + { + return; + } + + AnalyzeEventHandlerStartFromStopAt(context, eventHandlerAttribute, attributeSyntaxNode); + } + + + static void AnalyzeEventHandlerStartFromStopAt(SyntaxNodeAnalysisContext context, AttributeData eventHandlerAttribute, AttributeSyntax syntaxNode) + { + var startFrom = GetAttributeArgument(eventHandlerAttribute, syntaxNode, StartFromTimestampOffset, "startFromTimestamp"); + if (startFrom?.value is not null && !DateTimeOffset.TryParse(startFrom.Value.value.ToString(), out var parsedStartFrom)) + { + var diagnostic = Diagnostic.Create(DescriptorRules.InvalidTimestamp, startFrom.Value.location, "startFromTimestamp"); + context.ReportDiagnostic(diagnostic); + } + + var stopAt = GetAttributeArgument(eventHandlerAttribute, syntaxNode, StopAtTimestampOffset, "stopAtTimestamp"); + if (stopAt?.value is not null && !DateTimeOffset.TryParse(stopAt.Value.value.ToString(), out var parsedStopAt)) + { + var diagnostic = Diagnostic.Create(DescriptorRules.InvalidTimestamp, stopAt.Value.location, "stopAtTimestamp"); + context.ReportDiagnostic(diagnostic); + } + + if(parsedStartFrom > DateTimeOffset.MinValue && parsedStopAt > DateTimeOffset.MinValue && parsedStartFrom >= parsedStopAt) + { + var diagnostic = Diagnostic.Create(DescriptorRules.InvalidStartStopTimestamp, syntaxNode.GetLocation(), "startFromTimestamp", "stopAtTimestamp"); + context.ReportDiagnostic(diagnostic); + } + } + + public static (object? value, Location location)? GetAttributeArgument(AttributeData eventHandlerAttribute, AttributeSyntax syntaxNode, int offset, + string argumentName) + { + if (syntaxNode.ArgumentList is null) + { + return null; + } + + // Handle positional arguments + if (offset > eventHandlerAttribute.ConstructorArguments.Length) + { + return null; + } + + var argument = eventHandlerAttribute.ConstructorArguments[offset]; + + foreach (var argumentSyntax in syntaxNode.ArgumentList.Arguments) + { + if (argumentSyntax.NameColon?.Name?.Identifier.Text.Equals(argumentName, StringComparison.OrdinalIgnoreCase) == true) + { + return (argument.Value, argumentSyntax.GetLocation()); + } + } + + if (syntaxNode.ArgumentList.Arguments.Count > offset) + { + var positionalSyntax = syntaxNode.ArgumentList.Arguments[offset]; + return (argument.Value, positionalSyntax.GetLocation()); + } + + return (argument.Value, syntaxNode.GetLocation()); + } +} diff --git a/Tests/Analyzers/CodeFixProviderTests.cs b/Tests/Analyzers/CodeFixProviderTests.cs index e1845749..42979749 100644 --- a/Tests/Analyzers/CodeFixProviderTests.cs +++ b/Tests/Analyzers/CodeFixProviderTests.cs @@ -18,6 +18,9 @@ public abstract class CodeFixProviderTests : AnalyzerTest { TestCode = source, @@ -38,4 +41,9 @@ protected Task VerifyCodeFixAsync(string source, string expectedResult, Diagnost return test.RunAsync(CancellationToken.None); } + + string ToLfLineEndings(string source) + { + return source.Replace("\r\n", "\n"); + } } diff --git a/Tests/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProviderTests.cs b/Tests/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProviderTests.cs new file mode 100644 index 00000000..e60f5534 --- /dev/null +++ b/Tests/Analyzers/CodeFixes/EventHandlerEventContextCodeFixProviderTests.cs @@ -0,0 +1,89 @@ +// 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 EventHandlerEventContextCodeFixProviderTests: CodeFixProviderTests +{ + + [Fact] + public async Task ShouldGenerateEventContext() + { + var test = @"using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventType(""86dd35ee-cd28-48d9-a0cd-cb2aa11851aa"")] +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp: ""2023-09-15T07:30Z"")] +class SomeEventHandler +{ + public void Handle(SomeEvent evt) + { + System.Console.WriteLine(""Hello World""); + } +}"; + + var expected = @"using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventType(""86dd35ee-cd28-48d9-a0cd-cb2aa11851aa"")] +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp: ""2023-09-15T07:30Z"")] +class SomeEventHandler +{ + public void Handle(SomeEvent evt, EventContext ctx) + { + System.Console.WriteLine(""Hello World""); + } +}"; + + var diagnosticResult = Diagnostic(DescriptorRules.Events.MissingEventContext) + .WithSpan(10, 17, 10, 23) + .WithArguments("Handle"); + + await VerifyCodeFixAsync(test, expected, diagnosticResult); + } + + [Fact] + public async Task ShouldGenerateEventContextAndNamespace() + { + var test = @"using Dolittle.SDK.Events.Handling; + +[Dolittle.SDK.Events.EventType(""86dd35ee-cd28-48d9-a0cd-cb2aa11851aa"")] +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp: ""2023-09-15T07:30Z"")] +class SomeEventHandler +{ + public void Handle(SomeEvent evt) + { + System.Console.WriteLine(""Hello World""); + } +}"; + + var expected = @"using Dolittle.SDK.Events.Handling; +using Dolittle.SDK.Events; + +[Dolittle.SDK.Events.EventType(""86dd35ee-cd28-48d9-a0cd-cb2aa11851aa"")] +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp: ""2023-09-15T07:30Z"")] +class SomeEventHandler +{ + public void Handle(SomeEvent evt, EventContext ctx) + { + System.Console.WriteLine(""Hello World""); + } +}"; + + var diagnosticResult = Diagnostic(DescriptorRules.Events.MissingEventContext) + .WithSpan(9, 17, 9, 23) + .WithArguments("Handle"); + + await VerifyCodeFixAsync(test, expected, diagnosticResult); + } +} diff --git a/Tests/Analyzers/Diagnostics/AnnotationAnalyzerTests.cs b/Tests/Analyzers/Diagnostics/AnnotationAnalyzerTests.cs index 0b5a1172..35071406 100644 --- a/Tests/Analyzers/Diagnostics/AnnotationAnalyzerTests.cs +++ b/Tests/Analyzers/Diagnostics/AnnotationAnalyzerTests.cs @@ -134,4 +134,23 @@ class SomeEvent await VerifyAnalyzerFindsNothingAsync(test); } + + [Fact] + public async Task ShouldDetectEventHandlerWhenQualified() + { + var test = @" +[Dolittle.SDK.Events.Handling.EventHandler("""")] +class SomeEvent +{ + public string Name {get; set;} +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.InvalidIdentity) + .WithSpan(2, 2, 2, 47) + .WithArguments("Dolittle.SDK.Events.Handling.EventHandler", "eventHandlerId", "\"\""), + }; + + await VerifyAnalyzerAsync(test, expected); + } } diff --git a/Tests/Analyzers/Diagnostics/EventHandlerAnalyzerTests.cs b/Tests/Analyzers/Diagnostics/EventHandlerAnalyzerTests.cs new file mode 100644 index 00000000..ebb3b896 --- /dev/null +++ b/Tests/Analyzers/Diagnostics/EventHandlerAnalyzerTests.cs @@ -0,0 +1,269 @@ +// 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 EventHandlerAnalyzerTests : AnalyzerTest +{ + [Fact] + public async Task ShouldDetectNothing() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventType(""86dd35ee-cd28-48d9-a0cd-cb2aa11851aa"")] +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp:""2023-09-15T07:30Z"")] +class SomeEventHandler +{ + public void Handle(SomeEvent evt, EventContext context) + { + } +}"; + await VerifyAnalyzerFindsNothingAsync(test); + } + + [Fact] + public async Task ShouldDetectNothingAsync() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; +using System.Threading.Tasks; + +[EventType(""86dd35ee-cd28-48d9-a0cd-cb2aa11851aa"")] +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp:""2023-09-15T07:30Z"")] +class SomeEventHandler +{ + public async Task Handle(SomeEvent evt, EventContext context) + { + } +}"; + await VerifyAnalyzerFindsNothingAsync(test); + } + + [Fact] + public async Task ShouldDetectInvalidStartFromTimestamp() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, startFromTimestamp:""2023-09-15T07:30Z!"")] +class SomeEventHandler +{ + +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.InvalidTimestamp) + .WithSpan(5, 86, 5, 125) + .WithArguments("startFromTimestamp"), + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldDetectInvalidStopAtTimestamp() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp:""2023-09-15T07:30Z!"")] +class SomeEventHandler +{ + +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.InvalidTimestamp) + .WithSpan(5, 86, 5, 122) + .WithArguments("stopAtTimestamp"), + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldNotDetectDetectTimestampIssues() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFromTimestamp:""2023-09-15T06:25Z"", stopAtTimestamp:""2023-09-15T06:30Z"")] +class SomeEventHandler +{ + +}"; + + await VerifyAnalyzerFindsNothingAsync(test); + + } + + [Fact] + public async Task ShouldDetectDetectTimestampIssues() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", stopAtTimestamp:""2023-09-15T06:25Z"", startFromTimestamp:""2023-09-15T06:30Z"")] +class SomeEventHandler +{ + +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.InvalidStartStopTimestamp) + .WithSpan(5, 2, 5, 131) + .WithArguments("startFromTimestamp","stopAtTimestamp"), + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldDetectDetectMissingEventTypeAnnotation() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"")] +class SomeEventHandler +{ + public void Handle(SomeEvent evt, EventContext context) + { + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Events.MissingAttribute) + .WithSpan(10, 34, 10, 37) + .WithArguments("SomeEvent"), + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldDetectInvalidAccessibilityWhenNotSpecific() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventType(""86dd35ee-cd28-48d9-a0cd-cb2aa11851aa"")] +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp:""2023-09-15T07:30Z"")] +class SomeEventHandler +{ + void Handle(SomeEvent evt, EventContext context) + { + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.InvalidAccessibility) + .WithSpan(11, 10, 11, 16) + .WithArguments("Handle", "Public"), + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldDetectInvalidAccessibilityWhenPrivate() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventType(""86dd35ee-cd28-48d9-a0cd-cb2aa11851aa"")] +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp:""2023-09-15T07:30Z"")] +class SomeEventHandler +{ + private void Handle(SomeEvent evt, EventContext context) + { + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.InvalidAccessibility) + .WithSpan(11, 18, 11, 24) + .WithArguments("Handle", "Public"), + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldDetectInvalidAccessibilityWhenInternal() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventType(""86dd35ee-cd28-48d9-a0cd-cb2aa11851aa"")] +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp:""2023-09-15T07:30Z"")] +class SomeEventHandler +{ + internal void Handle(SomeEvent evt, EventContext context) + { + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.InvalidAccessibility) + .WithSpan(11, 19, 11, 25) + .WithArguments("Handle", "Public"), + }; + + await VerifyAnalyzerAsync(test, expected); + } + + [Fact] + public async Task ShouldDetectMissingEventContext() + { + var test = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Handling; + +[EventType(""86dd35ee-cd28-48d9-a0cd-cb2aa11851aa"")] +record SomeEvent(); + +[EventHandler(""86dd35ee-cd28-48d9-a0cd-cb2aa11851af"", startFrom: ProcessFrom.Latest, stopAtTimestamp:""2023-09-15T07:30Z"")] +class SomeEventHandler +{ + public void Handle(SomeEvent evt) + { + } +}"; + DiagnosticResult[] expected = + { + Diagnostic(DescriptorRules.Events.MissingEventContext) + .WithSpan(11, 17, 11, 23) + .WithArguments("Handle"), + }; + + await VerifyAnalyzerAsync(test, expected); + } + +}