diff --git a/Brighter.slnx b/Brighter.slnx index 794ccea98c..b163ff5f02 100644 --- a/Brighter.slnx +++ b/Brighter.slnx @@ -242,6 +242,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 34b9e51b87..089e81ff2b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -78,6 +78,7 @@ + diff --git a/samples/AsyncAPI/KafkaAsyncAPI/Program.cs b/samples/AsyncAPI/KafkaAsyncAPI/Program.cs index b0dd624236..450224bd38 100644 --- a/samples/AsyncAPI/KafkaAsyncAPI/Program.cs +++ b/samples/AsyncAPI/KafkaAsyncAPI/Program.cs @@ -81,6 +81,9 @@ THE SOFTWARE. */ new() { Topic = new RoutingKey("order.created"), + // Murmur2Random is recommended: its MurmurHash2 hash spreads keys evenly across + // partitions, avoiding hot partitions + Partitioner = Partitioner.Murmur2Random, NumPartitions = 3, MessageSendMaxRetries = 3, MessageTimeoutMs = 1000, diff --git a/samples/TaskQueue/KafkaDeferOnError/GreetingsSender/Program.cs b/samples/TaskQueue/KafkaDeferOnError/GreetingsSender/Program.cs index 05e9bc87b3..1b60daff81 100644 --- a/samples/TaskQueue/KafkaDeferOnError/GreetingsSender/Program.cs +++ b/samples/TaskQueue/KafkaDeferOnError/GreetingsSender/Program.cs @@ -59,6 +59,9 @@ THE SOFTWARE. */ { Topic = new RoutingKey("greeting.event"), RequestType = typeof(GreetingEvent), + // Murmur2Random is recommended: its MurmurHash2 hash spreads keys evenly across + // partitions, avoiding hot partitions + Partitioner = Partitioner.Murmur2Random, NumPartitions = 3, MessageSendMaxRetries = 3, MessageTimeoutMs = 1000, diff --git a/samples/TaskQueue/KafkaDontAckOnError/GreetingsSender/Program.cs b/samples/TaskQueue/KafkaDontAckOnError/GreetingsSender/Program.cs index 8f7df77794..3ed5f39bf6 100644 --- a/samples/TaskQueue/KafkaDontAckOnError/GreetingsSender/Program.cs +++ b/samples/TaskQueue/KafkaDontAckOnError/GreetingsSender/Program.cs @@ -59,6 +59,9 @@ THE SOFTWARE. */ { Topic = new RoutingKey("greeting.event"), RequestType = typeof(GreetingEvent), + // Murmur2Random is recommended: its MurmurHash2 hash spreads keys evenly across + // partitions, avoiding hot partitions + Partitioner = Partitioner.Murmur2Random, NumPartitions = 3, MessageSendMaxRetries = 3, MessageTimeoutMs = 1000, diff --git a/samples/TaskQueue/KafkaDynamicEventStream/TaskStatusSender/Program.cs b/samples/TaskQueue/KafkaDynamicEventStream/TaskStatusSender/Program.cs index d46a0d5caa..bacce93f23 100644 --- a/samples/TaskQueue/KafkaDynamicEventStream/TaskStatusSender/Program.cs +++ b/samples/TaskQueue/KafkaDynamicEventStream/TaskStatusSender/Program.cs @@ -52,6 +52,9 @@ THE SOFTWARE. */ //the same topic for both TaskCreated and TaskUpdated, but different cloud events types Topic = new RoutingKey("task.update"), Type = new CloudEventsType("io.goparamore.task.created"), + // Murmur2Random is recommended: its MurmurHash2 hash spreads keys evenly across + // partitions, avoiding hot partitions + Partitioner = Partitioner.Murmur2Random, NumPartitions = 3, MessageSendMaxRetries = 3, MessageTimeoutMs = 1000, @@ -61,6 +64,9 @@ THE SOFTWARE. */ { Topic = new RoutingKey("task.update"), Type = new CloudEventsType("io.goparamore.task.updated"), + // Murmur2Random is recommended: its MurmurHash2 hash spreads keys evenly across + // partitions, avoiding hot partitions + Partitioner = Partitioner.Murmur2Random, NumPartitions = 3, MessageSendMaxRetries = 3, MessageTimeoutMs = 1000, diff --git a/samples/TaskQueue/KafkaSchemaRegistry/GreetingsSender/Program.cs b/samples/TaskQueue/KafkaSchemaRegistry/GreetingsSender/Program.cs index addd77d556..8814c3416f 100644 --- a/samples/TaskQueue/KafkaSchemaRegistry/GreetingsSender/Program.cs +++ b/samples/TaskQueue/KafkaSchemaRegistry/GreetingsSender/Program.cs @@ -62,6 +62,9 @@ THE SOFTWARE. */ { Topic = new RoutingKey("greeting.event"), RequestType = typeof(GreetingEvent), + // Murmur2Random is recommended: its MurmurHash2 hash spreads keys evenly across + // partitions, avoiding hot partitions + Partitioner = Partitioner.Murmur2Random, MessageSendMaxRetries = 3, MessageTimeoutMs = 1000, MaxInFlightRequestsPerConnection = 1 diff --git a/samples/TaskQueue/KafkaTaskQueue/GreetingsSender/Program.cs b/samples/TaskQueue/KafkaTaskQueue/GreetingsSender/Program.cs index 26de70c991..0aa04092f8 100644 --- a/samples/TaskQueue/KafkaTaskQueue/GreetingsSender/Program.cs +++ b/samples/TaskQueue/KafkaTaskQueue/GreetingsSender/Program.cs @@ -80,6 +80,9 @@ THE SOFTWARE. */ { Topic = new RoutingKey("greeting.event"), RequestType = typeof(GreetingEvent), + // Murmur2Random is recommended: its MurmurHash2 hash spreads keys evenly across + // partitions, avoiding hot partitions + Partitioner = Partitioner.Murmur2Random, NumPartitions = 3, MessageSendMaxRetries = 3, MessageTimeoutMs = 1000, diff --git a/samples/TaskQueue/KafkaTaskQueueWithDLQ/GreetingsSender/Program.cs b/samples/TaskQueue/KafkaTaskQueueWithDLQ/GreetingsSender/Program.cs index 93f5444c43..badb8dd4fc 100644 --- a/samples/TaskQueue/KafkaTaskQueueWithDLQ/GreetingsSender/Program.cs +++ b/samples/TaskQueue/KafkaTaskQueueWithDLQ/GreetingsSender/Program.cs @@ -96,6 +96,9 @@ THE SOFTWARE. */ { Topic = new RoutingKey("greeting.event"), RequestType = typeof(GreetingEvent), + // Murmur2Random is recommended: its MurmurHash2 hash spreads keys evenly across + // partitions, avoiding hot partitions + Partitioner = Partitioner.Murmur2Random, NumPartitions = 3, MessageSendMaxRetries = 3, MessageTimeoutMs = 1000, diff --git a/samples/TaskQueue/MultiBus/GreetingsSender/Program.cs b/samples/TaskQueue/MultiBus/GreetingsSender/Program.cs index f389e4503b..6bc7c81333 100644 --- a/samples/TaskQueue/MultiBus/GreetingsSender/Program.cs +++ b/samples/TaskQueue/MultiBus/GreetingsSender/Program.cs @@ -72,6 +72,9 @@ THE SOFTWARE. */ { Topic = new RoutingKey("greeting.event"), RequestType = typeof(GreetingEvent), + // Murmur2Random is recommended: its MurmurHash2 hash spreads keys evenly across + // partitions, avoiding hot partitions + Partitioner = Partitioner.Murmur2Random, NumPartitions = 3, MessageSendMaxRetries = 3, MessageTimeoutMs = 1000, diff --git a/samples/WebAPI/WebAPI_Common/TransportMaker/ConfigureTransport.cs b/samples/WebAPI/WebAPI_Common/TransportMaker/ConfigureTransport.cs index 3730b9b839..bda408018f 100644 --- a/samples/WebAPI/WebAPI_Common/TransportMaker/ConfigureTransport.cs +++ b/samples/WebAPI/WebAPI_Common/TransportMaker/ConfigureTransport.cs @@ -97,6 +97,9 @@ public static IAmAProducerRegistry GetKafkaProducerRegistry() where T: class, { Topic = new RoutingKey(typeof(T).Name), RequestType = typeof(T), + // Murmur2Random is recommended: its MurmurHash2 hash spreads keys evenly across + // partitions, avoiding hot partitions + Partitioner = Paramore.Brighter.MessagingGateway.Kafka.Partitioner.Murmur2Random, MessageSendMaxRetries = 3, MessageTimeoutMs = 1000, MaxInFlightRequestsPerConnection = 1, diff --git a/src/Paramore.Brighter.Analyzer.CodeFixes/CodeFixes/MissingPartitionerCodeFixProvider.cs b/src/Paramore.Brighter.Analyzer.CodeFixes/CodeFixes/MissingPartitionerCodeFixProvider.cs new file mode 100644 index 0000000000..e97a2bac50 --- /dev/null +++ b/src/Paramore.Brighter.Analyzer.CodeFixes/CodeFixes/MissingPartitionerCodeFixProvider.cs @@ -0,0 +1,204 @@ +#region License +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +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; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Simplification; + +namespace Paramore.Brighter.Analyzer.CodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MissingPartitionerCodeFixProvider)), Shared] +public class MissingPartitionerCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => [DiagnosticsIds.MissingPartitioner]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + foreach (var diagnostic in context.Diagnostics) + { + var objectCreation = root.FindNode(diagnostic.Location.SourceSpan) + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + if (objectCreation == null) + { + continue; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Set 'Partitioner' to 'Partitioner.{BrighterAnalyzerGlobals.Murmur2RandomPartitionerValue}' (re-partitions the topic)", + createChangedDocument: ct => AddPartitionerAsync(context.Document, objectCreation, ct), + equivalenceKey: nameof(MissingPartitionerCodeFixProvider)), + diagnostic); + } + } + + private static async Task AddPartitionerAsync( + Document document, + BaseObjectCreationExpressionSyntax objectCreation, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + var assignment = SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName(BrighterAnalyzerGlobals.PartitionerProperty), + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.ParseExpression($"{BrighterAnalyzerGlobals.KafkaNamespace}.{BrighterAnalyzerGlobals.PartitionerEnum}"), + SyntaxFactory.IdentifierName(BrighterAnalyzerGlobals.Murmur2RandomPartitionerValue)) + .WithAdditionalAnnotations(Simplifier.Annotation)); + + InitializerExpressionSyntax initializer; + if (objectCreation.Initializer == null) + { + initializer = SyntaxFactory.InitializerExpression( + SyntaxKind.ObjectInitializerExpression, + SyntaxFactory.SingletonSeparatedList(assignment)); + } + else if (objectCreation.Initializer.Expressions.Count == 0) + { + // new KafkaPublication { } — keep the existing (empty) braces and trivia. + initializer = objectCreation.Initializer.WithExpressions( + SyntaxFactory.SingletonSeparatedList(assignment)); + } + else + { + initializer = AddInitializerExpression(objectCreation.Initializer, assignment); + } + + var newObjectCreation = objectCreation.WithInitializer(initializer); + + // Drop a now-redundant empty argument list: with an initializer present, + // new KafkaPublication { ... } reads better than new KafkaPublication() { ... }. + // Keep the argument list's trailing trivia (the space before the brace). + if (newObjectCreation is ObjectCreationExpressionSyntax { ArgumentList.Arguments.Count: 0 } explicitCreation) + { + newObjectCreation = explicitCreation + .WithType(explicitCreation.Type.WithTrailingTrivia(explicitCreation.ArgumentList.GetTrailingTrivia())) + .WithArgumentList(null); + } + + newObjectCreation = newObjectCreation.WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root!.ReplaceNode(objectCreation, newObjectCreation); + var formatted = await Formatter.FormatAsync(document.WithSyntaxRoot(newRoot), Formatter.Annotation, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Reduce the fully qualified Partitioner reference where the using is + // already present; otherwise keep it qualified so the fix always compiles. + return await Simplifier.ReduceAsync(formatted, Simplifier.Annotation, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static InitializerExpressionSyntax AddInitializerExpression( + InitializerExpressionSyntax initializer, + ExpressionSyntax expression) + { + // Build the new expression list by hand: AddExpressions would insert the + // separator comma right after the last expression but before its trailing + // trivia, so the comma lands on the wrong line and any trailing comment + // (e.g. "// one per shard") would move onto the new expression. + var nodesAndTokens = initializer.Expressions.GetWithSeparators(); + + // A trailing comma ("{ A = 1, B = 2, }") carries the closing-brace trivia: + // drop the comma and move its trivia onto the last expression. + if (nodesAndTokens.Count > 0 && nodesAndTokens[nodesAndTokens.Count - 1].IsToken) + { + var trailingComma = nodesAndTokens[nodesAndTokens.Count - 1].AsToken(); + nodesAndTokens = nodesAndTokens.RemoveAt(nodesAndTokens.Count - 1); + nodesAndTokens = nodesAndTokens.Replace( + nodesAndTokens[nodesAndTokens.Count - 1], + ((ExpressionSyntax)nodesAndTokens[nodesAndTokens.Count - 1].AsNode()!) + .WithTrailingTrivia(trailingComma.TrailingTrivia)); + } + + var lastExpression = (ExpressionSyntax)nodesAndTokens[nodesAndTokens.Count - 1].AsNode()!; + var trailingTrivia = lastExpression.GetTrailingTrivia(); + var beforeEndOfLine = trailingTrivia.TakeWhile(t => !t.IsKind(SyntaxKind.EndOfLineTrivia)).ToList(); + var fromEndOfLine = trailingTrivia.SkipWhile(t => !t.IsKind(SyntaxKind.EndOfLineTrivia)).ToList(); + + var separator = SyntaxFactory.Token(SyntaxKind.CommaToken); + ExpressionSyntax newExpression; + if (fromEndOfLine.Count == 0) + { + // Single-line initializer ("{ Topic = x }"): keep it on one line, + // with single spaces around the new expression. + var hasComment = beforeEndOfLine.Any(IsComment); + if (hasComment) + { + separator = separator.WithTrailingTrivia(beforeEndOfLine); + } + + newExpression = expression + .WithLeadingTrivia(SyntaxFactory.TriviaList(SyntaxFactory.Space)) + .WithTrailingTrivia(hasComment + ? SyntaxFactory.TriviaList(SyntaxFactory.Space) + : SyntaxFactory.TriviaList(beforeEndOfLine)); + } + else + { + // Multi-line: comments stay on their line attached to the comma, the + // newline + indent that follows an existing separator (or the open + // brace) leads the new expression, and the newline before the closing + // brace moves behind it. + separator = separator.WithTrailingTrivia(beforeEndOfLine); + var leadingTrivia = nodesAndTokens.Count > 1 + ? nodesAndTokens[nodesAndTokens.Count - 2].AsToken().TrailingTrivia + : initializer.OpenBraceToken.TrailingTrivia; + newExpression = expression + .WithLeadingTrivia(leadingTrivia) + .WithTrailingTrivia(fromEndOfLine); + } + + return initializer.WithExpressions( + SyntaxFactory.SeparatedList( + nodesAndTokens + .Replace(nodesAndTokens[nodesAndTokens.Count - 1], lastExpression.WithoutTrailingTrivia()) + .Add(separator) + .Add(newExpression))); + } + + private static bool IsComment(SyntaxTrivia trivia) + { + return trivia.IsKind(SyntaxKind.SingleLineCommentTrivia) || trivia.IsKind(SyntaxKind.MultiLineCommentTrivia); + } +} diff --git a/src/Paramore.Brighter.Analyzer.CodeFixes/CodeFixes/PartitionerValueCodeFixProvider.cs b/src/Paramore.Brighter.Analyzer.CodeFixes/CodeFixes/PartitionerValueCodeFixProvider.cs new file mode 100644 index 0000000000..44e3e96b83 --- /dev/null +++ b/src/Paramore.Brighter.Analyzer.CodeFixes/CodeFixes/PartitionerValueCodeFixProvider.cs @@ -0,0 +1,118 @@ +#region License +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +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 Paramore.Brighter.Analyzer.CodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PartitionerValueCodeFixProvider)), Shared] +public class PartitionerValueCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => [DiagnosticsIds.ConsistentRandomPartitioner, DiagnosticsIds.ConsistentPartitioner]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + foreach (var diagnostic in context.Diagnostics) + { + var target = diagnostic.Id == DiagnosticsIds.ConsistentRandomPartitioner + ? BrighterAnalyzerGlobals.Murmur2RandomPartitionerValue + : BrighterAnalyzerGlobals.Murmur2PartitionerValue; + + var assignment = root.FindNode(diagnostic.Location.SourceSpan) + .DescendantNodesAndSelf() + .OfType() + .FirstOrDefault(a => a.Left switch + { + // Partitioner = ... (object initializer) or publication.Partitioner = ... (post-construction) + IdentifierNameSyntax id => id.Identifier.ValueText == BrighterAnalyzerGlobals.PartitionerProperty, + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.ValueText == BrighterAnalyzerGlobals.PartitionerProperty, + _ => false + }); + + if (assignment == null || !CanRewrite(assignment.Right)) + { + // Only a member access (Partitioner.Consistent), a parenthesized + // member access, or a bare identifier (using static) can be + // rewritten safely; anything else (e.g. a cast) would not + // compile after the fix, so don't offer one. + continue; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Use 'Partitioner.{target}' (re-partitions the topic)", + createChangedDocument: ct => ReplacePartitionerValueAsync(context.Document, assignment, target, ct), + equivalenceKey: $"{nameof(PartitionerValueCodeFixProvider)}:{target}"), + diagnostic); + } + } + + private static bool CanRewrite(ExpressionSyntax right) + { + return right is MemberAccessExpressionSyntax + or IdentifierNameSyntax + or ParenthesizedExpressionSyntax { Expression: MemberAccessExpressionSyntax }; + } + + private static async Task ReplacePartitionerValueAsync( + Document document, + AssignmentExpressionSyntax assignment, + string target, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + var newName = SyntaxFactory.IdentifierName(target); + ExpressionSyntax newValue = assignment.Right switch + { + MemberAccessExpressionSyntax memberAccess => memberAccess.WithName(newName), + ParenthesizedExpressionSyntax { Expression: MemberAccessExpressionSyntax memberAccess } parenthesized => + parenthesized.WithExpression(memberAccess.WithName(newName)), + _ => newName + }; + + var newRoot = root!.ReplaceNode( + assignment.Right, + newValue.WithTriviaFrom(assignment.Right)); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/Paramore.Brighter.Analyzer.CodeFixes/Paramore.Brighter.Analyzer.CodeFixes.csproj b/src/Paramore.Brighter.Analyzer.CodeFixes/Paramore.Brighter.Analyzer.CodeFixes.csproj new file mode 100644 index 0000000000..57966b1e48 --- /dev/null +++ b/src/Paramore.Brighter.Analyzer.CodeFixes/Paramore.Brighter.Analyzer.CodeFixes.csproj @@ -0,0 +1,20 @@ + + + + $(BrighterNetStandardTargetFrameworks) + false + enable + + + + + + + + + + + + diff --git a/src/Paramore.Brighter.Analyzer.Package/Paramore.Brighter.Analyzer.Package.csproj b/src/Paramore.Brighter.Analyzer.Package/Paramore.Brighter.Analyzer.Package.csproj index 23ffadb7c7..a36fab7d16 100644 --- a/src/Paramore.Brighter.Analyzer.Package/Paramore.Brighter.Analyzer.Package.csproj +++ b/src/Paramore.Brighter.Analyzer.Package/Paramore.Brighter.Analyzer.Package.csproj @@ -20,15 +20,21 @@ + + + + + diff --git a/src/Paramore.Brighter.Analyzer.Package/build/Paramore.Brighter.Analyzer.Package.props b/src/Paramore.Brighter.Analyzer.Package/build/Paramore.Brighter.Analyzer.Package.props new file mode 100644 index 0000000000..8f7bec5577 --- /dev/null +++ b/src/Paramore.Brighter.Analyzer.Package/build/Paramore.Brighter.Analyzer.Package.props @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Paramore.Brighter.Analyzer.Package/build/Paramore.Brighter.Analyzer.props b/src/Paramore.Brighter.Analyzer.Package/build/Paramore.Brighter.Analyzer.props deleted file mode 100644 index ead6e08f09..0000000000 --- a/src/Paramore.Brighter.Analyzer.Package/build/Paramore.Brighter.Analyzer.props +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/Paramore.Brighter.Analyzer.Package/configuration/default.editorconfig b/src/Paramore.Brighter.Analyzer.Package/configuration/default.editorconfig index c4458e30f1..609f507acb 100644 --- a/src/Paramore.Brighter.Analyzer.Package/configuration/default.editorconfig +++ b/src/Paramore.Brighter.Analyzer.Package/configuration/default.editorconfig @@ -1,5 +1,10 @@ +is_global = true + dotnet_diagnostic.BRT001.severity = warning dotnet_diagnostic.BRT002.severity = warning dotnet_diagnostic.BRT003.severity = warning dotnet_diagnostic.BRT004.severity = warning -dotnet_diagnostic.BRT005.severity = warning \ No newline at end of file +dotnet_diagnostic.BRT005.severity = warning +dotnet_diagnostic.BRT006.severity = warning +dotnet_diagnostic.BRT007.severity = warning +dotnet_diagnostic.BRT008.severity = warning diff --git a/src/Paramore.Brighter.Analyzer.Package/configuration/none.editorconfig b/src/Paramore.Brighter.Analyzer.Package/configuration/none.editorconfig index aef953fc0e..0b5d80477c 100644 --- a/src/Paramore.Brighter.Analyzer.Package/configuration/none.editorconfig +++ b/src/Paramore.Brighter.Analyzer.Package/configuration/none.editorconfig @@ -1,5 +1,10 @@ +is_global = true + dotnet_diagnostic.BRT001.severity = none dotnet_diagnostic.BRT002.severity = none dotnet_diagnostic.BRT003.severity = none dotnet_diagnostic.BRT004.severity = none -dotnet_diagnostic.BRT005.severity = none \ No newline at end of file +dotnet_diagnostic.BRT005.severity = none +dotnet_diagnostic.BRT006.severity = none +dotnet_diagnostic.BRT007.severity = none +dotnet_diagnostic.BRT008.severity = none diff --git a/src/Paramore.Brighter.Analyzer/AnalyzerReleases.Shipped.md b/src/Paramore.Brighter.Analyzer/AnalyzerReleases.Shipped.md index a32c29b2e2..f92f0d2f08 100644 --- a/src/Paramore.Brighter.Analyzer/AnalyzerReleases.Shipped.md +++ b/src/Paramore.Brighter.Analyzer/AnalyzerReleases.Shipped.md @@ -8,4 +8,7 @@ BRT001 | Design | Warning | ([BRT001](./docs/BRT001.md)) Request Type assign BRT002 | Design | Warning | ([BRT002](./docs/BRT002.md)) RequestType is not child of IRequest BRT003 | Design | Warning | ([BRT003](./docs/BRT003.md)) MessagePump assignment is Missing BRT004 | Design | Warning | ([BRT004](./docs/BRT004.md)) Wrap attribute is applied to wrong Method -BRT005 | Design | Warning | ([BRT005](./docs/BRT005.md)) UnWrap attribute is applied to wrong Method \ No newline at end of file +BRT005 | Design | Warning | ([BRT005](./docs/BRT005.md)) UnWrap attribute is applied to wrong Method +BRT006 | Design | Warning | ([BRT006](./docs/BRT006.md)) Missing Partitioner assignment +BRT007 | Design | Warning | ([BRT007](./docs/BRT007.md)) ConsistentRandom Partitioner Used +BRT008 | Design | Warning | ([BRT008](./docs/BRT008.md)) Consistent Partitioner Used diff --git a/src/Paramore.Brighter.Analyzer/Analyzers/KafkaPublicationPartitionerAnalyzer.cs b/src/Paramore.Brighter.Analyzer/Analyzers/KafkaPublicationPartitionerAnalyzer.cs new file mode 100644 index 0000000000..003949cd5f --- /dev/null +++ b/src/Paramore.Brighter.Analyzer/Analyzers/KafkaPublicationPartitionerAnalyzer.cs @@ -0,0 +1,335 @@ +#region License + +/* The MIT License (MIT) +Copyright © 2026 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; +using Paramore.Brighter.Analyzer.Visitors.Operation; + +namespace Paramore.Brighter.Analyzer.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class KafkaPublicationPartitionerAnalyzer : DiagnosticAnalyzer +{ + private const string PartitionerCategory = "Design"; + + public static readonly DiagnosticDescriptor MissingPartitionerRule = new( + id: DiagnosticsIds.MissingPartitioner, + title: "Missing Partitioner", + messageFormat: "Partitioner assignment is missing from {0}. Consider setting it explicitly.", + category: PartitionerCategory, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Setting the Partitioner explicitly makes the choice visible. Be aware that changing the partitioner re-partitions the topic; existing publications can keep the implicit default to preserve their current partition assignment.", + helpLinkUri: "https://github.com/BrighterCommand/Brighter/blob/master/src/Paramore.Brighter.Analyzer/docs/BRT006.md" + ); + + public static readonly DiagnosticDescriptor ConsistentRandomPartitionerRule = new( + id: DiagnosticsIds.ConsistentRandomPartitioner, + title: "ConsistentRandom Partitioner Used", + messageFormat: "Prefer 'Murmur2Random' over 'ConsistentRandom' for new KafkaPublications", + category: PartitionerCategory, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "'Murmur2Random' spreads keys more evenly across partitions than the CRC32-based 'ConsistentRandom', avoiding hot partitions. Existing publications can keep 'ConsistentRandom' to preserve their current partition assignment.", + helpLinkUri: "https://github.com/BrighterCommand/Brighter/blob/master/src/Paramore.Brighter.Analyzer/docs/BRT007.md" + ); + + public static readonly DiagnosticDescriptor ConsistentPartitionerRule = new( + id: DiagnosticsIds.ConsistentPartitioner, + title: "Consistent Partitioner Used", + messageFormat: "Prefer 'Murmur2' over 'Consistent' for new KafkaPublications", + category: PartitionerCategory, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "'Murmur2' spreads keys more evenly across partitions than the CRC32-based 'Consistent', avoiding hot partitions. Existing publications can keep 'Consistent' to preserve their current partition assignment.", + helpLinkUri: "https://github.com/BrighterCommand/Brighter/blob/master/src/Paramore.Brighter.Analyzer/docs/BRT008.md" + ); + + public override ImmutableArray SupportedDiagnostics => [MissingPartitionerRule, ConsistentRandomPartitionerRule, ConsistentPartitionerRule]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(compilationContext => + { + // Solutions that don't reference the Kafka gateway can never create a + // KafkaPublication; don't pay for an operation callback there at all. + // Resolve the symbols once and compare by symbol from here on. + var kafkaPublicationSymbol = compilationContext.Compilation.GetTypeByMetadataName( + $"{BrighterAnalyzerGlobals.KafkaNamespace}.{BrighterAnalyzerGlobals.KafkaPublicationClassName}"); + var partitionerEnumSymbol = compilationContext.Compilation.GetTypeByMetadataName( + $"{BrighterAnalyzerGlobals.KafkaNamespace}.{BrighterAnalyzerGlobals.PartitionerEnum}"); + if (kafkaPublicationSymbol == null || partitionerEnumSymbol == null) + { + return; + } + + // Memoises the constructor inspection per type, so a subclass + // instantiated in many places is only walked once per compilation. + var constructorCheckCache = new ConcurrentDictionary(SymbolEqualityComparer.Default); + + compilationContext.RegisterOperationAction( + operationContext => AnalyzerObjectCreation(operationContext, kafkaPublicationSymbol, partitionerEnumSymbol, constructorCheckCache), + OperationKind.ObjectCreation); + compilationContext.RegisterOperationAction( + operationContext => AnalyzeAssignment(operationContext, kafkaPublicationSymbol, partitionerEnumSymbol), + OperationKind.SimpleAssignment); + }); + } + + private static void AnalyzerObjectCreation( + OperationAnalysisContext context, + INamedTypeSymbol kafkaPublicationSymbol, + INamedTypeSymbol partitionerEnumSymbol, + ConcurrentDictionary constructorCheckCache) + { + var operation = (IObjectCreationOperation)context.Operation; + + // Cheap rejection before allocating the visitor; most object creations + // in a compilation are not KafkaPublications. + if (!KafkaPublicationPartitionerVisitor.IsKafkaPublicationType(operation.Type, kafkaPublicationSymbol)) + { + return; + } + + var visitor = new KafkaPublicationPartitionerVisitor(kafkaPublicationSymbol, partitionerEnumSymbol); + operation.Accept(visitor); + + if (!visitor.IsPartitionerAssigned) + { + if (HasPartitionerAssignmentAfterConstruction(operation) || + SetsPartitionerInConstructor(operation.Type, kafkaPublicationSymbol, constructorCheckCache, context.CancellationToken)) + { + // The partitioner is set on the instance after construction or by + // the type's own constructor; any discouraged value is reported + // by AnalyzeAssignment instead. + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + MissingPartitionerRule, + GetCreationLocation(operation.Syntax), + visitor.PublicationName)); + } + else if (visitor.IsConsistentRandom) + { + context.ReportDiagnostic(Diagnostic.Create( + ConsistentRandomPartitionerRule, + visitor.PartitionerAssignmentLocation)); + } + else if (visitor.IsConsistent) + { + context.ReportDiagnostic(Diagnostic.Create( + ConsistentPartitionerRule, + visitor.PartitionerAssignmentLocation)); + } + } + + // Report on the type name (or the `new` keyword for a target-typed new) + // rather than the whole creation, so a large initializer isn't squiggled + // in full. + private static Location GetCreationLocation(SyntaxNode creationSyntax) + { + return creationSyntax switch + { + ObjectCreationExpressionSyntax objectCreation => objectCreation.Type.GetLocation(), + ImplicitObjectCreationExpressionSyntax implicitCreation => implicitCreation.NewKeyword.GetLocation(), + _ => creationSyntax.GetLocation() + }; + } + + // A subclass can set the partitioner in its own constructor, e.g.: + // class OrdersPublication : KafkaPublication + // { + // public OrdersPublication() { Partitioner = Partitioner.Murmur2Random; } + // } + // Don't report BRT006 for such a type — an initializer added by the code fix + // would override the subclass's deliberate choice. Only constructors declared + // below KafkaPublication itself are considered; its own Partitioner default is + // exactly what BRT006 flags as implicit. + private static bool SetsPartitionerInConstructor( + ITypeSymbol? type, + INamedTypeSymbol kafkaPublicationSymbol, + ConcurrentDictionary constructorCheckCache, + CancellationToken cancellationToken) + { + for (var current = type as INamedTypeSymbol; + current != null && !SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, kafkaPublicationSymbol); + current = current.BaseType) + { + var assigns = constructorCheckCache.GetOrAdd( + current, + symbol => symbol.InstanceConstructors.Any(constructor => ConstructorAssignsPartitioner(constructor, cancellationToken))); + + if (assigns) + { + return true; + } + } + + return false; + } + + // Syntactic check (analyzers must not call Compilation.GetSemanticModel, RS1030): + // an assignment to `Partitioner` or `this.Partitioner` in the constructor body. + // In a KafkaPublication subclass constructor an unqualified `Partitioner` can + // only bind to the inherited property or a local of the same name — the latter + // is contrived and accepted. + private static bool ConstructorAssignsPartitioner(IMethodSymbol constructor, CancellationToken cancellationToken) + { + foreach (var syntaxReference in constructor.DeclaringSyntaxReferences) + { + var assignsPartitioner = syntaxReference.GetSyntax(cancellationToken) + .DescendantNodes() + .OfType() + .Any(assignment => assignment.Left switch + { + IdentifierNameSyntax id => id.Identifier.ValueText == BrighterAnalyzerGlobals.PartitionerProperty, + MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax } memberAccess => + memberAccess.Name.Identifier.ValueText == BrighterAnalyzerGlobals.PartitionerProperty, + _ => false + }); + + if (assignsPartitioner) + { + return true; + } + } + + return false; + } + + // Reports discouraged partitioner values assigned outside an object + // initializer, e.g.: + // var publication = new KafkaPublication(); + // publication.Partitioner = Partitioner.Consistent; + // The diagnostic is reported from the assignment's own callback so it stays + // local to the analyzed operation. + private static void AnalyzeAssignment( + OperationAnalysisContext context, + INamedTypeSymbol kafkaPublicationSymbol, + INamedTypeSymbol partitionerEnumSymbol) + { + var assignment = (ISimpleAssignmentOperation)context.Operation; + + // Assignments inside an object creation's initializer + // (new KafkaPublication { Partitioner = ... }) are handled by + // AnalyzerObjectCreation. Assignments in a nested member initializer + // (new Holder { Publication = { Partitioner = ... } }) — whose parent + // initializer hangs off an IMemberInitializerOperation, not a creation — + // ARE handled here. + if (assignment.Parent is IObjectOrCollectionInitializerOperation { Parent: not IMemberInitializerOperation }) + { + return; + } + + if (assignment.Target is not IPropertyReferenceOperation propertyReference || + propertyReference.Property.Name != BrighterAnalyzerGlobals.PartitionerProperty || + !KafkaPublicationPartitionerVisitor.IsKafkaPublicationType(propertyReference.Property.ContainingType, kafkaPublicationSymbol)) + { + return; + } + + switch (KafkaPublicationPartitionerVisitor.GetPartitionerValueName(assignment.Value, partitionerEnumSymbol)) + { + case BrighterAnalyzerGlobals.ConsistentRandomPartitionerValue: + context.ReportDiagnostic(Diagnostic.Create( + ConsistentRandomPartitionerRule, + assignment.Syntax.GetLocation())); + break; + case BrighterAnalyzerGlobals.ConsistentPartitionerValue: + context.ReportDiagnostic(Diagnostic.Create( + ConsistentPartitionerRule, + assignment.Syntax.GetLocation())); + break; + } + } + + // Checks whether the partitioner is assigned on the just-created instance + // later in the same block, e.g.: + // var publication = new KafkaPublication(); + // publication.Partitioner = Partitioner.Murmur2Random; + // Works for locals, fields, properties and parameters, and sees assignments + // inside nested blocks (if/else/loops). Assignments made before the + // construction, or elsewhere (helper methods, other blocks), are not tracked. + private static bool HasPartitionerAssignmentAfterConstruction(IObjectCreationOperation operation) + { + ISymbol? symbol = operation.Parent switch + { + IVariableInitializerOperation { Parent: IVariableDeclaratorOperation declarator } => declarator.Symbol, + ISimpleAssignmentOperation { Target: ILocalReferenceOperation localReference } => localReference.Local, + ISimpleAssignmentOperation { Target: IFieldReferenceOperation fieldReference } => fieldReference.Field, + ISimpleAssignmentOperation { Target: IPropertyReferenceOperation propertyReference } => propertyReference.Property, + ISimpleAssignmentOperation { Target: IParameterReferenceOperation parameterReference } => parameterReference.Parameter, + _ => null + }; + + if (symbol == null) + { + return false; + } + + var ancestor = operation.Parent; + while (ancestor != null && ancestor is not IBlockOperation) + { + ancestor = ancestor.Parent; + } + + if (ancestor is not IBlockOperation block) + { + return false; + } + + return block.Descendants() + .OfType() + // Field/property targets are compared by symbol, not instance: an + // assignment through another object sharing the field (a.Pub vs b.Pub) + // would also match — an accepted, contrived edge case. + .Any(assignment => + assignment.Syntax.SpanStart > operation.Syntax.SpanStart && + assignment.Target is IPropertyReferenceOperation propertyReference && + propertyReference.Property.Name == BrighterAnalyzerGlobals.PartitionerProperty && + IsReferenceTo(propertyReference.Instance, symbol)); + } + + private static bool IsReferenceTo(IOperation? instance, ISymbol symbol) + { + return instance switch + { + ILocalReferenceOperation localReference => SymbolEqualityComparer.Default.Equals(localReference.Local, symbol), + IFieldReferenceOperation fieldReference => SymbolEqualityComparer.Default.Equals(fieldReference.Field, symbol), + IPropertyReferenceOperation propertyReference => SymbolEqualityComparer.Default.Equals(propertyReference.Property, symbol), + IParameterReferenceOperation parameterReference => SymbolEqualityComparer.Default.Equals(parameterReference.Parameter, symbol), + _ => false + }; + } +} diff --git a/src/Paramore.Brighter.Analyzer/BrighterAnalyzerGlobals.cs b/src/Paramore.Brighter.Analyzer/BrighterAnalyzerGlobals.cs index 4ad546154d..6c219b0749 100644 --- a/src/Paramore.Brighter.Analyzer/BrighterAnalyzerGlobals.cs +++ b/src/Paramore.Brighter.Analyzer/BrighterAnalyzerGlobals.cs @@ -1,4 +1,4 @@ -#region License +#region License /* The MIT License (MIT) Copyright © 2026 Aboubakr Nasef @@ -22,22 +22,33 @@ THE SOFTWARE. */ #endregion -namespace Paramore.Brighter.Analyzer +namespace Paramore.Brighter.Analyzer; + +public static class BrighterAnalyzerGlobals { - public class BrighterAnalyzerGlobals - { - public const string PublicationClassName = "Publication"; - public const string BrighterAssembly = "Paramore.Brighter"; - public const string RequestTypeProperty = "RequestType"; - public const string IRequestInterface = "IRequest"; - - public const string MessagePumpTypeEnumName = "MessagePumpType"; - public const string SubscriptionClassName = "Subscription"; - - public const string MessageMapperInterface = "IAmAMessageMapper"; - public const string UnwrapWithAttribute = "UnwrapWithAttribute"; - public const string WrapWithAttribute = "WrapWithAttribute"; - public const string MapToMessage = "MapToMessage"; - public const string MapToRequest = "MapToRequest"; - } + public const string PublicationClassName = "Publication"; + public const string KafkaPublicationClassName = "KafkaPublication"; + public const string BrighterAssembly = "Paramore.Brighter"; + public const string KafkaNamespace = "Paramore.Brighter.MessagingGateway.Kafka"; + public const string RequestTypeProperty = "RequestType"; + + // PartitionerProperty (the KafkaPublication property) and PartitionerEnum (the + // enum type) intentionally share the value "Partitioner" — they name different + // symbols and could diverge. + public const string PartitionerProperty = "Partitioner"; + public const string PartitionerEnum = "Partitioner"; + public const string ConsistentRandomPartitionerValue = "ConsistentRandom"; + public const string ConsistentPartitionerValue = "Consistent"; + public const string Murmur2RandomPartitionerValue = "Murmur2Random"; + public const string Murmur2PartitionerValue = "Murmur2"; + public const string IRequestInterface = "IRequest"; + + public const string MessagePumpTypeEnumName = "MessagePumpType"; + public const string SubscriptionClassName = "Subscription"; + + public const string MessageMapperInterface = "IAmAMessageMapper"; + public const string UnwrapWithAttribute = "UnwrapWithAttribute"; + public const string WrapWithAttribute = "WrapWithAttribute"; + public const string MapToMessage = "MapToMessage"; + public const string MapToRequest = "MapToRequest"; } diff --git a/src/Paramore.Brighter.Analyzer/DiagnosticsIds.cs b/src/Paramore.Brighter.Analyzer/DiagnosticsIds.cs index 96e86e3c18..36237104c2 100644 --- a/src/Paramore.Brighter.Analyzer/DiagnosticsIds.cs +++ b/src/Paramore.Brighter.Analyzer/DiagnosticsIds.cs @@ -22,15 +22,16 @@ THE SOFTWARE. */ #endregion +namespace Paramore.Brighter.Analyzer; -namespace Paramore.Brighter.Analyzer +public static class DiagnosticsIds { - public static class DiagnosticsIds - { - public const string RequestTypeMissing = "BRT001"; - public const string WrongRequestType = "BRT002"; - public const string MessagePumpMissing = "BRT003"; - public const string WrapWithAttribute = "BRT004"; - public const string UnWrapWithAttribute = "BRT005"; - } + public const string RequestTypeMissing = "BRT001"; + public const string WrongRequestType = "BRT002"; + public const string MessagePumpMissing = "BRT003"; + public const string WrapWithAttribute = "BRT004"; + public const string UnWrapWithAttribute = "BRT005"; + public const string MissingPartitioner = "BRT006"; + public const string ConsistentRandomPartitioner = "BRT007"; + public const string ConsistentPartitioner = "BRT008"; } diff --git a/src/Paramore.Brighter.Analyzer/Paramore.Brighter.Analyzer.csproj b/src/Paramore.Brighter.Analyzer/Paramore.Brighter.Analyzer.csproj index 71264c5b3a..94588fea2e 100644 --- a/src/Paramore.Brighter.Analyzer/Paramore.Brighter.Analyzer.csproj +++ b/src/Paramore.Brighter.Analyzer/Paramore.Brighter.Analyzer.csproj @@ -1,4 +1,4 @@ - + $(BrighterNetStandardTargetFrameworks) @@ -6,6 +6,7 @@ Analyzers for the brighter library Analyzer;Scheduler;Message Scheduling;Command Processor;Brighter false + enable true true diff --git a/src/Paramore.Brighter.Analyzer/Visitors/Operation/KafkaPublicationPartitionerVisitor.cs b/src/Paramore.Brighter.Analyzer/Visitors/Operation/KafkaPublicationPartitionerVisitor.cs new file mode 100644 index 0000000000..bcec77851c --- /dev/null +++ b/src/Paramore.Brighter.Analyzer/Visitors/Operation/KafkaPublicationPartitionerVisitor.cs @@ -0,0 +1,118 @@ +#region License +/* The MIT License (MIT) +Copyright © 2026 Aboubakr Nasef + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace Paramore.Brighter.Analyzer.Visitors.Operation; + +public class KafkaPublicationPartitionerVisitor : OperationWalker +{ + private readonly INamedTypeSymbol _kafkaPublicationSymbol; + private readonly INamedTypeSymbol _partitionerEnumSymbol; + + public KafkaPublicationPartitionerVisitor(INamedTypeSymbol kafkaPublicationSymbol, INamedTypeSymbol partitionerEnumSymbol) + { + _kafkaPublicationSymbol = kafkaPublicationSymbol; + _partitionerEnumSymbol = partitionerEnumSymbol; + } + + public bool IsPartitionerAssigned { get; private set; } + public bool IsConsistentRandom { get; private set; } + public bool IsConsistent { get; private set; } + public string? PublicationName { get; private set; } + public Location? PartitionerAssignmentLocation { get; private set; } + + public override void VisitObjectCreation(IObjectCreationOperation operation) + { + if (IsKafkaPublicationType(operation.Type, _kafkaPublicationSymbol)) + { + PublicationName = operation.Type!.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + + // base walks the children (including the initializer), which drives + // VisitSimpleAssignment for any Partitioner assignment. Only descend + // when this operation is the KafkaPublication itself; reporting for + // unrelated object creations would attribute nested publications to + // the wrong location. Note this also descends into nested object + // creations, so a nested KafkaPublication carrying its own Partitioner + // would mark the outer one as assigned too — a contrived edge case + // accepted for simplicity. + base.VisitObjectCreation(operation); + } + } + + public override void VisitSimpleAssignment(ISimpleAssignmentOperation operation) + { + if (operation.Target is IPropertyReferenceOperation propertyReference && + propertyReference.Property.Name == BrighterAnalyzerGlobals.PartitionerProperty && + IsKafkaPublicationType(propertyReference.Property.ContainingType, _kafkaPublicationSymbol)) + { + IsPartitionerAssigned = true; + PartitionerAssignmentLocation = operation.Syntax.GetLocation(); + + switch (GetPartitionerValueName(operation.Value, _partitionerEnumSymbol)) + { + case BrighterAnalyzerGlobals.ConsistentRandomPartitionerValue: + IsConsistentRandom = true; + break; + case BrighterAnalyzerGlobals.ConsistentPartitionerValue: + IsConsistent = true; + break; + } + } + + base.VisitSimpleAssignment(operation); + } + + // Type can be null for erroneous code in the IDE; treat it as no match. + internal static bool IsKafkaPublicationType(ITypeSymbol? type, INamedTypeSymbol kafkaPublicationSymbol) + { + for (var current = type; current != null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, kafkaPublicationSymbol)) + { + return true; + } + } + + return false; + } + + internal static string? GetPartitionerValueName(IOperation value, INamedTypeSymbol partitionerEnumSymbol) + { + // Unwrap an implicit conversion (e.g. enum widening) if present. + if (value is IConversionOperation conversion) + { + value = conversion.Operand; + } + + // Only fields of the Kafka Partitioner enum itself count; a user field + // that merely shares a member name (e.g. `Defaults.Consistent`) must not + // be treated as the enum value. + return value is IFieldReferenceOperation fieldReference && + SymbolEqualityComparer.Default.Equals(fieldReference.Field.ContainingType, partitionerEnumSymbol) + ? fieldReference.Field.Name + : null; + } +} diff --git a/src/Paramore.Brighter.Analyzer/Visitors/Operation/RequestTypeAssignmentVisitor.cs b/src/Paramore.Brighter.Analyzer/Visitors/Operation/RequestTypeAssignmentVisitor.cs index cc276c4ed1..e65583d6bd 100644 --- a/src/Paramore.Brighter.Analyzer/Visitors/Operation/RequestTypeAssignmentVisitor.cs +++ b/src/Paramore.Brighter.Analyzer/Visitors/Operation/RequestTypeAssignmentVisitor.cs @@ -1,4 +1,4 @@ -#region License +#region License /* The MIT License (MIT) Copyright © 2026 Aboubakr Nasef @@ -34,10 +34,10 @@ public class RequestTypeAssignmentVisitor : OperationWalker { public bool IsPublicationType { get; private set; } public bool IsRequestTypeAssigned { get; private set; } - public string PublicationName { get; private set; } + public string? PublicationName { get; private set; } public bool IsNotTypeOfIRequest { get; private set; } - public Location TypeOfLocation { get; private set; } - public string TypeOfName { get; private set; } + public Location? TypeOfLocation { get; private set; } + public string? TypeOfName { get; private set; } public override void VisitObjectCreation(IObjectCreationOperation operation) { diff --git a/src/Paramore.Brighter.Analyzer/Visitors/Operation/SubscriptionConstructorVisitor.cs b/src/Paramore.Brighter.Analyzer/Visitors/Operation/SubscriptionConstructorVisitor.cs index 58fd98a830..31114fe9ab 100644 --- a/src/Paramore.Brighter.Analyzer/Visitors/Operation/SubscriptionConstructorVisitor.cs +++ b/src/Paramore.Brighter.Analyzer/Visitors/Operation/SubscriptionConstructorVisitor.cs @@ -1,4 +1,4 @@ -#region License +#region License /* The MIT License (MIT) Copyright © 2026 Aboubakr Nasef @@ -30,7 +30,7 @@ namespace Paramore.Brighter.Analyzer.Visitors.Operation public class SubscriptionConstructorVisitor : OperationWalker { public bool IsMessagePumpDefault { get; private set; } = false; - public string SubscriptionName { get; private set; } + public string? SubscriptionName { get; private set; } public bool IsSubscriptionType { get; private set; } public override void VisitObjectCreation(IObjectCreationOperation operation) @@ -44,7 +44,7 @@ public override void VisitObjectCreation(IObjectCreationOperation operation) } public override void VisitArgument(IArgumentOperation operation) { - if (operation.Value.Type.Name == BrighterAnalyzerGlobals.MessagePumpTypeEnumName && operation.ArgumentKind == ArgumentKind.DefaultValue) + if (operation.Value.Type?.Name == BrighterAnalyzerGlobals.MessagePumpTypeEnumName && operation.ArgumentKind == ArgumentKind.DefaultValue) { IsMessagePumpDefault = true; } diff --git a/src/Paramore.Brighter.Analyzer/docs/BRT006.md b/src/Paramore.Brighter.Analyzer/docs/BRT006.md new file mode 100644 index 0000000000..95096e6563 --- /dev/null +++ b/src/Paramore.Brighter.Analyzer/docs/BRT006.md @@ -0,0 +1,36 @@ +# BRT006: Missing Partitioner + +## Description +This rule detects a `KafkaPublication` that is created without an explicit `Partitioner` assignment. + +## Why is this a warning? +When the `Partitioner` is not set explicitly, the publication falls back to the default partitioning strategy — which is `Partitioner.ConsistentRandom`. Omitting the assignment therefore silently selects the same value that [BRT007](./BRT007.md) discourages, without that choice being visible in the code. Because the implicit case has the same partition-distribution impact as the explicit one, it is reported at the same **Warning** severity rather than as a lower-priority suggestion. + +The Brighter team wants users who work with Kafka to make the `Partitioner` choice explicit. The partitioner determines how message keys are distributed across a topic's partitions, and its impact — hash algorithm, keyless-message handling, and the risk of uneven load or *hot partitions* — is not common knowledge among many people who use Kafka. Setting it explicitly makes that decision visible in the code and prompts the author to understand the trade-offs (see [BRT007](./BRT007.md) and [BRT008](./BRT008.md)) rather than inheriting a default whose behaviour they may not be aware of. + +## How to fix +Set the `Partitioner` explicitly on the `KafkaPublication`. `Partitioner.Murmur2Random` is the recommended value for new publications. + +**Changing the partitioner changes runtime behaviour.** Applying the fix to an existing publication moves it from the implicit `ConsistentRandom` default to `Murmur2Random`, which re-partitions the topic — keys will map to different partitions than before. + +**Keeping the current partition assignment.** No warning-free value preserves the existing key-to-partition mapping: the only value that matches the implicit default is `Partitioner.ConsistentRandom`, which [BRT007](./BRT007.md) flags — the warning-free values (`Murmur2Random`, `Murmur2`, `Random`) all re-partition the topic. To keep the current assignment while still making the choice visible in code, set `Partitioner = Partitioner.ConsistentRandom` explicitly and suppress BRT007 for that publication. Alternatively, leave it implicit and suppress BRT006. + +The rule recognizes a `Partitioner` assignment made in the object initializer, one made directly on the same instance (local, field, property or parameter) later in the same block — including inside nested blocks such as `if` statements — and one made by the constructor of a `KafkaPublication` subclass. This check is textual, not flow analysis: an assignment on a single conditional branch (`if (useKeys) publication.Partitioner = …`) or inside a loop counts as "set", even on paths where it never runs. Assignments made elsewhere — for example inside a helper method that configures the publication — are not tracked, so the warning may still fire for publications that are configured that way; suppress it in that case. Subclass constructors can only be inspected when the subclass is declared in source — a subclass from a referenced assembly that sets `Partitioner` in its constructor will still be flagged; suppress it there too. + +### Example +```csharp +// Warning: Partitioner assignment is missing +var publication = new KafkaPublication +{ + // ... other properties +}; + +// Fixed: Partitioner assigned explicitly +var publication = new KafkaPublication +{ + Partitioner = Partitioner.Murmur2Random + // ... other properties +}; +``` + +A code fix is available that adds `Partitioner = Partitioner.Murmur2Random` to the publication initializer. diff --git a/src/Paramore.Brighter.Analyzer/docs/BRT007.md b/src/Paramore.Brighter.Analyzer/docs/BRT007.md new file mode 100644 index 0000000000..cd999ccb99 --- /dev/null +++ b/src/Paramore.Brighter.Analyzer/docs/BRT007.md @@ -0,0 +1,29 @@ +# BRT007: ConsistentRandom Partitioner Used + +## Description +This rule detects a `KafkaPublication` whose `Partitioner` is set to `Partitioner.ConsistentRandom`. + +## Why is this a warning? +`Murmur2Random` is preferred over `ConsistentRandom` for new `KafkaPublications`. Both hash the message key to select a partition and spread keyless messages randomly, but they use different hash algorithms: `ConsistentRandom` uses CRC32, while `Murmur2Random` uses MurmurHash2 — the algorithm the standard Java Kafka producer uses. `Murmur2Random` is functionally equivalent to the Java producer's default partitioner, so a given key lands on the same partition for Brighter and for the standard Kafka clients. When other producers or consumers rely on key-based routing or ordering, that cross-client compatibility is what keeps a key on the partition the rest of the ecosystem expects. + +Existing publications that already rely on `ConsistentRandom` can safely ignore this warning to preserve their current partition assignment. + +## How to fix +Change the `Partitioner` from `Partitioner.ConsistentRandom` to `Partitioner.Murmur2Random`. + +### Example +```csharp +// Warning: ConsistentRandom used +var publication = new KafkaPublication +{ + Partitioner = Partitioner.ConsistentRandom +}; + +// Fixed: prefer Murmur2Random +var publication = new KafkaPublication +{ + Partitioner = Partitioner.Murmur2Random +}; +``` + +A code fix is available that replaces `Partitioner.ConsistentRandom` with `Partitioner.Murmur2Random`. diff --git a/src/Paramore.Brighter.Analyzer/docs/BRT008.md b/src/Paramore.Brighter.Analyzer/docs/BRT008.md new file mode 100644 index 0000000000..69d39d1e56 --- /dev/null +++ b/src/Paramore.Brighter.Analyzer/docs/BRT008.md @@ -0,0 +1,33 @@ +# BRT008: Consistent Partitioner Used + +## Description +This rule detects a `KafkaPublication` whose `Partitioner` is set to `Partitioner.Consistent`. + +## Why is this a warning? +`Murmur2` is preferred over `Consistent` for new `KafkaPublications`. Both hash the message key to select a partition, but `Murmur2` uses the MurmurHash2 algorithm, which spreads keys more evenly across partitions than the CRC32-based hash used by `Consistent`. Murmur2 is also the algorithm the standard Java Kafka producer uses for keyed messages, so a given key lands on the same partition for Brighter and for the standard Kafka clients — which matters when other producers or consumers rely on key-based routing or ordering. + +An uneven hash concentrates a disproportionate share of keys onto a few partitions — the *hot partition* problem. Because each partition is served by a single consumer within a consumer group and a single broker as its leader, a hot partition becomes a throughput bottleneck: it lags and backs up while the remaining partitions sit under-used, so the topic can no longer scale across all of its partitions. `Murmur2`'s more uniform distribution keeps load balanced and avoids this. + +Like `Consistent`, `Murmur2` pins messages with empty or NULL keys to a single partition. If the publication sends keyless messages, prefer `Partitioner.Murmur2Random` (see [BRT007](./BRT007.md)) instead, which spreads them across partitions. + +Existing publications that already rely on `Consistent` can safely ignore this warning to preserve their current partition assignment. + +## How to fix +Change the `Partitioner` from `Partitioner.Consistent` to `Partitioner.Murmur2`. + +### Example +```csharp +// Warning: Consistent used +var publication = new KafkaPublication +{ + Partitioner = Partitioner.Consistent +}; + +// Fixed: prefer Murmur2 +var publication = new KafkaPublication +{ + Partitioner = Partitioner.Murmur2 +}; +``` + +A code fix is available that replaces `Partitioner.Consistent` with `Partitioner.Murmur2`. diff --git a/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/BaseAnalyzerTest.cs b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/BaseAnalyzerTest.cs index 7f3e4da868..dfdc8353e0 100644 --- a/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/BaseAnalyzerTest.cs +++ b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/BaseAnalyzerTest.cs @@ -1,24 +1,28 @@ - -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Testing; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Testing; using Paramore.Brighter.Analyzer.Analyzers; -namespace Paramore.Brighter.Analyzer.Tests.Analyzers +namespace Paramore.Brighter.Analyzer.Tests.Analyzers; + +public abstract class BaseAnalyzerTest + where T : DiagnosticAnalyzer, new() { - public abstract class BaseAnalyzerTest where T : DiagnosticAnalyzer, new() + protected CSharpAnalyzerTest testContext; + + protected BaseAnalyzerTest() { - protected CSharpAnalyzerTest testContext; - protected BaseAnalyzerTest() + testContext = new CSharpAnalyzerTest { - testContext = new CSharpAnalyzerTest - { - ReferenceAssemblies = ReferenceAssemblies.Net.Net90 - }; - testContext.TestState.OutputKind = OutputKind.ConsoleApplication; - testContext.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(Publication).Assembly.Location)); - testContext.CompilerDiagnostics = CompilerDiagnostics.None; - } + ReferenceAssemblies = ReferenceAssemblies.Net.Net90, + }; + + testContext.TestState.OutputKind = OutputKind.ConsoleApplication; + testContext.TestState.AdditionalReferences.Add( + MetadataReference.CreateFromFile(typeof(Publication).Assembly.Location) + ); + + testContext.CompilerDiagnostics = CompilerDiagnostics.None; } } diff --git a/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/BaseKafkaAnalyzerTest.cs b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/BaseKafkaAnalyzerTest.cs new file mode 100644 index 0000000000..3a7eb059b7 --- /dev/null +++ b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/BaseKafkaAnalyzerTest.cs @@ -0,0 +1,19 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Paramore.Brighter.Analyzer.Analyzers; + +namespace Paramore.Brighter.Analyzer.Tests.Analyzers; + +public abstract class BaseKafkaAnalyzerTest : BaseAnalyzerTest +{ + protected BaseKafkaAnalyzerTest() + { + testContext.TestState.OutputKind = OutputKind.DynamicallyLinkedLibrary; + testContext.TestState.AdditionalReferences.Add( + MetadataReference.CreateFromFile( + typeof(Paramore.Brighter.MessagingGateway.Kafka.KafkaPublication).Assembly.Location + ) + ); + testContext.CompilerDiagnostics = CompilerDiagnostics.Errors; + } +} diff --git a/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/KafkaPublicationPartitionerAnalyzerTest.cs b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/KafkaPublicationPartitionerAnalyzerTest.cs new file mode 100644 index 0000000000..fb8ae5b81b --- /dev/null +++ b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/KafkaPublicationPartitionerAnalyzerTest.cs @@ -0,0 +1,798 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Paramore.Brighter.Analyzer.Analyzers; +using Xunit; + +namespace Paramore.Brighter.Analyzer.Tests.Analyzers; + +public class KafkaPublicationPartitionerAnalyzerTest : BaseKafkaAnalyzerTest +{ + [Fact] + public async Task When_KafkaPublication_Is_Created_Without_Partitioner_Should_Report_Missing_Partitioner() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|}(); + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_KafkaPublication_Generic_Is_Created_Without_Partitioner_Should_Report_Missing_Partitioner() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class MyRequest : IRequest + { + public Id Id { get; set; } + public Id? CorrelationId { get; set; } + } + + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|}(); + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_KafkaPublication_Is_Created_With_ConsistentRandom_Should_Report_Warning() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + {|#0:Partitioner = Partitioner.ConsistentRandom|} + }; + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentRandomPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_KafkaPublication_Is_Created_With_Consistent_Should_Report_Warning() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + {|#0:Partitioner = Partitioner.Consistent|} + }; + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_KafkaPublication_Is_Created_With_Murmur2Random_Should_Not_Report() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + Partitioner = Partitioner.Murmur2Random + }; + } + } +} +"""; + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_KafkaPublication_Without_Partitioner_Is_Nested_In_Another_Object_Creation_Should_Report_Once_At_Publication() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class Holder + { + public Holder(KafkaPublication publication) { } + } + + class TypeName + { + public void Method() + { + var holder = new Holder(new {|#0:KafkaPublication|}()); + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_KafkaPublication_With_Consistent_Is_Nested_In_Another_Object_Creation_Should_Report_Once_At_Publication() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class Holder + { + public Holder(KafkaPublication publication) { } + } + + class TypeName + { + public void Method() + { + var holder = new Holder(new KafkaPublication + { + {|#0:Partitioner = Partitioner.Consistent|} + }); + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_KafkaPublication_Is_Created_With_Random_Should_Not_Report() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + Partitioner = Partitioner.Random + }; + } + } +} +"""; + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Nested_Object_Has_Own_Partitioner_Property_Should_Still_Report_Missing_Partitioner() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class Config + { + public int Partitioner { get; set; } + } + + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|} + { + DefaultHeaders = new System.Collections.Generic.Dictionary + { + ["key"] = new Config { Partitioner = 3 } + } + }; + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Set_After_Construction_Should_Not_Report() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication(); + publication.Partitioner = Partitioner.Murmur2Random; + } + } +} +"""; + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Consistent_Is_Set_After_Construction_Should_Report_Warning_At_Assignment() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication(); + {|#0:publication.Partitioner = Partitioner.Consistent|}; + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_ConsistentRandom_Is_Set_After_Construction_Should_Report_Warning_At_Assignment() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication(); + {|#0:publication.Partitioner = Partitioner.ConsistentRandom|}; + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentRandomPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Plain_Publication_Is_Created_Should_Not_Report() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new Publication(); + } + } +} +"""; + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_KafkaPublication_Subclass_Is_Created_Without_Partitioner_Should_Report_Missing_Partitioner() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class MyPublication : KafkaPublication + { + } + + class TypeName + { + public void Method() + { + var publication = new {|#0:MyPublication|}(); + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("MyPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Set_On_Field_After_Construction_Should_Not_Report() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + private KafkaPublication _publication; + + public void Method() + { + _publication = new KafkaPublication(); + _publication.Partitioner = Partitioner.Murmur2Random; + } + } +} +"""; + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Set_Before_Construction_Should_Still_Report_Missing_Partitioner() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + private KafkaPublication _publication; + + public void Method() + { + _publication.Partitioner = Partitioner.Murmur2Random; + _publication = new {|#0:KafkaPublication|}(); + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_KafkaPublication_Is_Created_With_Target_Typed_New_Without_Partitioner_Should_Report_Missing_Partitioner() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + KafkaPublication publication = {|#0:new|}(); + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_KafkaPublication_Generic_Is_Created_With_ConsistentRandom_Should_Report_Warning() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class MyRequest : IRequest + { + public Id Id { get; set; } + public Id? CorrelationId { get; set; } + } + + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + {|#0:Partitioner = Partitioner.ConsistentRandom|} + }; + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentRandomPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Subclass_Sets_Partitioner_In_Constructor_Should_Not_Report() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class OrdersPublication : KafkaPublication + { + public OrdersPublication() + { + Partitioner = Partitioner.Murmur2Random; + } + } + + class TypeName + { + public void Method() + { + var publication = new OrdersPublication(); + } + } +} +"""; + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Subclass_Sets_Consistent_In_Constructor_Should_Report_Warning_At_Assignment() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class OrdersPublication : KafkaPublication + { + public OrdersPublication() + { + {|#0:Partitioner = Partitioner.Consistent|}; + } + } + + class TypeName + { + public void Method() + { + var publication = new OrdersPublication(); + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Value_Is_A_User_Field_Named_Like_The_Enum_Member_Should_Not_Report() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + static class Defaults + { + public static readonly Partitioner Consistent = Partitioner.Murmur2; + } + + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + Partitioner = Defaults.Consistent + }; + } + } +} +"""; + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Consistent_Is_Set_In_Nested_Member_Initializer_Should_Report_Warning_At_Assignment() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class Holder + { + public KafkaPublication Publication { get; set; } = new KafkaPublication { Partitioner = Partitioner.Murmur2Random }; + } + + class TypeName + { + public void Method() + { + var holder = new Holder + { + Publication = { {|#0:Partitioner = Partitioner.Consistent|} } + }; + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Set_On_Parameter_After_Construction_Should_Not_Report() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method(KafkaPublication publication) + { + publication = new KafkaPublication(); + publication.Partitioner = Partitioner.Murmur2Random; + } + } +} +"""; + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Set_On_Property_After_Construction_Should_Not_Report() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + private KafkaPublication Publication { get; set; } + + public void Method() + { + Publication = new KafkaPublication(); + Publication.Partitioner = Partitioner.Murmur2Random; + } + } +} +"""; + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Target_Typed_New_Is_Created_With_Consistent_Should_Report_Warning() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + KafkaPublication publication = new() + { + {|#0:Partitioner = Partitioner.Consistent|} + }; + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Consistent_Is_Set_In_Nested_Block_Should_Report_Only_Warning_At_Assignment() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method(bool legacy) + { + var publication = new KafkaPublication(); + if (legacy) + { + {|#0:publication.Partitioner = Partitioner.Consistent|}; + } + } + } +} +"""; + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } +} diff --git a/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/PublicationRequestTypeAssignmentAnalyzerTest.cs b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/PublicationRequestTypeAssignmentAnalyzerTest.cs index a8e904138a..978f26bfed 100644 --- a/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/PublicationRequestTypeAssignmentAnalyzerTest.cs +++ b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/PublicationRequestTypeAssignmentAnalyzerTest.cs @@ -2,17 +2,16 @@ using Paramore.Brighter.Analyzer.Analyzers; using Paramore.Brighter.Analyzer.Tests.Analyzers; +namespace Paramore.Brighter.Analyzer.Test.Analyzers; -namespace Paramore.Brighter.Analyzer.Test.Analyzers +public class PublicationRequestTypeAssignmentAnalyzerTest + : BaseAnalyzerTest { - public class PublicationRequestTypeAssignmentAnalyzerTest : BaseAnalyzerTest + [Fact] + public async Task When_Initializing_Publication_WithOut_RequestType() { - - [Fact] - public async Task When_Initializing_Publication_WithOut_RequestType() - { - - testContext.TestCode = /* lang=c#-test */ """ + testContext.TestCode = /* lang=c#-test */ + """ using Paramore.Brighter; namespace TestNamespace { @@ -24,14 +23,19 @@ public class PublicationTest : Publication } """; - testContext.ExpectedDiagnostics.Add(new DiagnosticResult(PublicationRequestTypeAssignmentAnalyzer.RequestTypeMissingRule).WithLocation(0).WithArguments("PublicationTest")); - await testContext.RunAsync(); - } + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(PublicationRequestTypeAssignmentAnalyzer.RequestTypeMissingRule) + .WithLocation(0) + .WithArguments("PublicationTest") + ); + await testContext.RunAsync(); + } - [Fact] - public async Task When_Initializing_Publication_With_Right_RequestType() - { - testContext.TestCode = /* lang=c#-test */ """ + [Fact] + public async Task When_Initializing_Publication_With_Right_RequestType() + { + testContext.TestCode = /* lang=c#-test */ + """ using Paramore.Brighter; namespace TestNamespace { @@ -51,13 +55,14 @@ public EventSample(Id id) : base(id) } } """; - await testContext.RunAsync(); - } + await testContext.RunAsync(); + } - [Fact] - public async Task When_Initializing_Publication_With_Wrong_RequestType() - { - testContext.TestCode = /* lang=c#-test */ """ + [Fact] + public async Task When_Initializing_Publication_With_Wrong_RequestType() + { + testContext.TestCode = /* lang=c#-test */ + """ using Paramore.Brighter; namespace TestNamespace { @@ -71,15 +76,20 @@ public class PublicationTest : Publication public class EventSample{} } """; - testContext.ExpectedDiagnostics.Add(new DiagnosticResult(PublicationRequestTypeAssignmentAnalyzer.WrongRequestTypeRule).WithLocation(0).WithArguments("EventSample")); + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(PublicationRequestTypeAssignmentAnalyzer.WrongRequestTypeRule) + .WithLocation(0) + .WithArguments("EventSample") + ); - await testContext.RunAsync(); - } + await testContext.RunAsync(); + } - [Fact] - public async Task When_Initializing_Non_Publication_Type() - { - testContext.TestCode = /* lang=c#-test */ """ + [Fact] + public async Task When_Initializing_Non_Publication_Type() + { + testContext.TestCode = /* lang=c#-test */ + """ using System.Collections.Generic; namespace TestNamespace { @@ -92,7 +102,6 @@ public void Method() } } """; - await testContext.RunAsync(); - } + await testContext.RunAsync(); } } diff --git a/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/SubscriptionConstructorAnalyzerTest.cs b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/SubscriptionConstructorAnalyzerTest.cs index 8d2bf4d8a2..4fdab08f56 100644 --- a/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/SubscriptionConstructorAnalyzerTest.cs +++ b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/SubscriptionConstructorAnalyzerTest.cs @@ -2,15 +2,15 @@ using Paramore.Brighter.Analyzer.Analyzers; using Paramore.Brighter.Analyzer.Tests.Analyzers; -namespace Paramore.Brighter.Analyzer.Test.Analyzers +namespace Paramore.Brighter.Analyzer.Test.Analyzers; + +public class SubscriptionConstructorAnalyzerTest : BaseAnalyzerTest { - public class SubscriptionConstructorAnalyzerTest : BaseAnalyzerTest + [Fact] + public async Task When_Initializing_Subscription_With_MessagePump() { - [Fact] - public async Task When_Initializing_Subscription_With_MessagePump() - { - - testContext.TestCode = /* lang=c#-test */ """ + testContext.TestCode = /* lang=c#-test */ + """ using Paramore.Brighter; namespace TestNamespace { @@ -25,14 +25,14 @@ public SubscriptionTest(SubscriptionName subscriptionName, ChannelName channelNa } """; - await testContext.RunAsync(); - } - - [Fact] - public async Task When_Initializing_Subscription_WithOut_MessagePump() - { + await testContext.RunAsync(); + } - testContext.TestCode = /* lang=c#-test */ """ + [Fact] + public async Task When_Initializing_Subscription_WithOut_MessagePump() + { + testContext.TestCode = /* lang=c#-test */ + """ using Paramore.Brighter; namespace TestNamespace { @@ -47,14 +47,19 @@ public SubscriptionTest(SubscriptionName subscriptionName, ChannelName channelNa } """; - testContext.ExpectedDiagnostics.Add(new DiagnosticResult(SubscriptionConstructorAnalyzer.MessagePumpMissingRule).WithLocation(0).WithArguments("SubscriptionTest")); - await testContext.RunAsync(); - } - [Fact] - public async Task When_Initializing_SubscriptionNested_WithOut_MessagePump() - { + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(SubscriptionConstructorAnalyzer.MessagePumpMissingRule) + .WithLocation(0) + .WithArguments("SubscriptionTest") + ); + await testContext.RunAsync(); + } - testContext.TestCode = /* lang=c#-test */ """ + [Fact] + public async Task When_Initializing_SubscriptionNested_WithOut_MessagePump() + { + testContext.TestCode = /* lang=c#-test */ + """ using Paramore.Brighter; namespace TestNamespace { @@ -75,9 +80,11 @@ public SubscriptionTestNested(SubscriptionName subscriptionName, ChannelName cha } """; - testContext.ExpectedDiagnostics.Add(new DiagnosticResult(SubscriptionConstructorAnalyzer.MessagePumpMissingRule).WithLocation(0).WithArguments("SubscriptionTestNested")); - await testContext.RunAsync(); - } - + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(SubscriptionConstructorAnalyzer.MessagePumpMissingRule) + .WithLocation(0) + .WithArguments("SubscriptionTestNested") + ); + await testContext.RunAsync(); } } diff --git a/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/WrapAttributeAnalyzerTest.cs b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/WrapAttributeAnalyzerTest.cs index 8ad50ccffd..a212215c7e 100644 --- a/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/WrapAttributeAnalyzerTest.cs +++ b/tests/Paramore.Brighter.Analyzer.Tests/Analyzers/WrapAttributeAnalyzerTest.cs @@ -2,16 +2,15 @@ using Paramore.Brighter.Analyzer.Analyzers; using Paramore.Brighter.Analyzer.Tests.Analyzers; -namespace Paramore.Brighter.Analyzer.Test.Analyzers +namespace Paramore.Brighter.Analyzer.Test.Analyzers; + +public class WrapAttributeAnalyzerTest : BaseAnalyzerTest { - public class WrapAttributeAnalyzerTest: BaseAnalyzerTest + [Fact] + public async Task When_Adding_Attribute_To_MessageMapper() { - - [Fact] - public async Task When_Adding_Attribute_To_MessageMapper() - { - - testContext.TestCode = /* lang=c#-test */ """ + testContext.TestCode = /* lang=c#-test */ + """ using Paramore.Brighter; using Paramore.Brighter.Transforms.Attributes; namespace TestNamespace @@ -38,9 +37,16 @@ public class SampleEvent(Id id) : Event(id) } """; - testContext.ExpectedDiagnostics.Add(new DiagnosticResult(WrapAttributeAnalyzer.WrapAttributeRule ).WithLocation(0).WithArguments("CompressAttribute")); - testContext.ExpectedDiagnostics.Add(new DiagnosticResult(WrapAttributeAnalyzer.UnWrapWithAttributeRule ).WithLocation(1).WithArguments("DecompressAttribute")); - await testContext.RunAsync(); - } + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(WrapAttributeAnalyzer.WrapAttributeRule) + .WithLocation(0) + .WithArguments("CompressAttribute") + ); + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(WrapAttributeAnalyzer.UnWrapWithAttributeRule) + .WithLocation(1) + .WithArguments("DecompressAttribute") + ); + await testContext.RunAsync(); } } diff --git a/tests/Paramore.Brighter.Analyzer.Tests/CodeFixes/BaseCodeFixTest.cs b/tests/Paramore.Brighter.Analyzer.Tests/CodeFixes/BaseCodeFixTest.cs new file mode 100644 index 0000000000..0fcd53bfc7 --- /dev/null +++ b/tests/Paramore.Brighter.Analyzer.Tests/CodeFixes/BaseCodeFixTest.cs @@ -0,0 +1,35 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace Paramore.Brighter.Analyzer.Tests.CodeFixes; + +public abstract class BaseCodeFixTest + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() +{ + protected CSharpCodeFixTest testContext; + + protected BaseCodeFixTest() + { + testContext = new CSharpCodeFixTest + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net90, + }; + + testContext.TestState.OutputKind = OutputKind.DynamicallyLinkedLibrary; + testContext.TestState.AdditionalReferences.Add( + MetadataReference.CreateFromFile(typeof(Publication).Assembly.Location) + ); + + testContext.TestState.AdditionalReferences.Add( + MetadataReference.CreateFromFile( + typeof(Paramore.Brighter.MessagingGateway.Kafka.KafkaPublication).Assembly.Location + ) + ); + + testContext.CompilerDiagnostics = CompilerDiagnostics.Errors; + } +} diff --git a/tests/Paramore.Brighter.Analyzer.Tests/CodeFixes/MissingPartitionerCodeFixProviderTest.cs b/tests/Paramore.Brighter.Analyzer.Tests/CodeFixes/MissingPartitionerCodeFixProviderTest.cs new file mode 100644 index 0000000000..171647e95f --- /dev/null +++ b/tests/Paramore.Brighter.Analyzer.Tests/CodeFixes/MissingPartitionerCodeFixProviderTest.cs @@ -0,0 +1,634 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Paramore.Brighter.Analyzer.Analyzers; +using Paramore.Brighter.Analyzer.CodeFixes; +using Xunit; + +namespace Paramore.Brighter.Analyzer.Tests.CodeFixes; + +public class MissingPartitionerCodeFixProviderTest + : BaseCodeFixTest +{ + [Fact] + public async Task When_Partitioner_Is_Missing_Should_Add_Murmur2Random() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|}(); + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication { Partitioner = Partitioner.Murmur2Random }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Kafka_Using_Is_Missing_Should_Add_Fully_Qualified_Partitioner() + { + testContext.TestCode = /* lang=c#-test */ + """ +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new {|#0:Paramore.Brighter.MessagingGateway.Kafka.KafkaPublication|}(); + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new Paramore.Brighter.MessagingGateway.Kafka.KafkaPublication { Partitioner = Paramore.Brighter.MessagingGateway.Kafka.Partitioner.Murmur2Random }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Missing_Should_Append_To_Existing_Initializer() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|} + { + Topic = new RoutingKey("x"), + NumPartitions = 3 + }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + Topic = new RoutingKey("x"), + NumPartitions = 3, + Partitioner = Partitioner.Murmur2Random + }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Missing_Should_Append_After_Trailing_Comment() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|} + { + Topic = new RoutingKey("x"), + NumPartitions = 3 // one per shard + }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + Topic = new RoutingKey("x"), + NumPartitions = 3, // one per shard + Partitioner = Partitioner.Murmur2Random + }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Missing_On_Target_Typed_New_Should_Add_Murmur2Random() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + KafkaPublication publication = {|#0:new|}(); + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + KafkaPublication publication = new() + { + Partitioner = Partitioner.Murmur2Random + }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Missing_On_Single_Line_Initializer_Should_Append_On_Same_Line() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|} { Topic = new RoutingKey("x") }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication { Topic = new RoutingKey("x"), Partitioner = Partitioner.Murmur2Random }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task FixAll_Should_Add_Partitioner_To_All_Publications() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var first = new {|#0:KafkaPublication|}(); + var second = new {|#1:KafkaPublication|}(); + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var first = new KafkaPublication { Partitioner = Partitioner.Murmur2Random }; + var second = new KafkaPublication { Partitioner = Partitioner.Murmur2Random }; + } + } +} +"""; + + testContext.BatchFixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var first = new KafkaPublication { Partitioner = Partitioner.Murmur2Random }; + var second = new KafkaPublication { Partitioner = Partitioner.Murmur2Random }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(1) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Missing_On_Empty_Initializer_Should_Add_Murmur2Random() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|} { }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication { Partitioner = Partitioner.Murmur2Random }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Missing_Should_Append_After_Trailing_Comma() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|} + { + Topic = new RoutingKey("x"), + NumPartitions = 3, + }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + Topic = new RoutingKey("x"), + NumPartitions = 3, + Partitioner = Partitioner.Murmur2Random + }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Missing_Should_Append_After_Single_Expression_Trailing_Comma() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|} { Topic = new RoutingKey("x"), }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication { Topic = new RoutingKey("x"), Partitioner = Partitioner.Murmur2Random }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Missing_On_Generic_Should_Add_Murmur2Random() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class MyRequest : IRequest + { + public Id Id { get; set; } + public Id? CorrelationId { get; set; } + } + + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|}(); + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class MyRequest : IRequest + { + public Id Id { get; set; } + public Id? CorrelationId { get; set; } + } + + class TypeName + { + public void Method() + { + var publication = new KafkaPublication { Partitioner = Partitioner.Murmur2Random }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Partitioner_Is_Ambiguous_With_Confluent_Should_Add_Fully_Qualified_Partitioner() + { + testContext.TestState.AdditionalReferences.Add( + MetadataReference.CreateFromFile( + Path.Combine( + Path.GetDirectoryName(typeof(Paramore.Brighter.MessagingGateway.Kafka.KafkaPublication).Assembly.Location), + "Confluent.Kafka.dll"))); + + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; +using Confluent.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new {|#0:KafkaPublication|}(); + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; +using Confluent.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication { Partitioner = Paramore.Brighter.MessagingGateway.Kafka.Partitioner.Murmur2Random }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult(KafkaPublicationPartitionerAnalyzer.MissingPartitionerRule) + .WithLocation(0) + .WithArguments("KafkaPublication") + ); + + await testContext.RunAsync(); + } +} diff --git a/tests/Paramore.Brighter.Analyzer.Tests/CodeFixes/PartitionerValueCodeFixProviderTest.cs b/tests/Paramore.Brighter.Analyzer.Tests/CodeFixes/PartitionerValueCodeFixProviderTest.cs new file mode 100644 index 0000000000..9dbb7d7db9 --- /dev/null +++ b/tests/Paramore.Brighter.Analyzer.Tests/CodeFixes/PartitionerValueCodeFixProviderTest.cs @@ -0,0 +1,390 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Paramore.Brighter.Analyzer.Analyzers; +using Paramore.Brighter.Analyzer.CodeFixes; +using Xunit; + +namespace Paramore.Brighter.Analyzer.Tests.CodeFixes; + +public class PartitionerValueCodeFixProviderTest + : BaseCodeFixTest +{ + [Fact] + public async Task When_ConsistentRandom_Is_Used_Should_Offer_Murmur2Random() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + {|#0:Partitioner = Partitioner.ConsistentRandom|} + }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + Partitioner = Partitioner.Murmur2Random + }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentRandomPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Consistent_Is_Used_Should_Offer_Murmur2() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + {|#0:Partitioner = Partitioner.Consistent|} + }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + Partitioner = Partitioner.Murmur2 + }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Bare_Identifier_Via_Using_Static_Is_Used_Should_Offer_Murmur2Random() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; +using static Paramore.Brighter.MessagingGateway.Kafka.Partitioner; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + {|#0:Partitioner = ConsistentRandom|} + }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; +using static Paramore.Brighter.MessagingGateway.Kafka.Partitioner; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + Partitioner = Murmur2Random + }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentRandomPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Consistent_Is_Set_After_Construction_Should_Offer_Murmur2() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication(); + {|#0:publication.Partitioner = Partitioner.Consistent|}; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication(); + publication.Partitioner = Partitioner.Murmur2; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Value_Is_Parenthesized_Should_Offer_Murmur2() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + {|#0:Partitioner = (Partitioner.Consistent)|} + }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + Partitioner = (Partitioner.Murmur2) + }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task When_Value_Is_A_Cast_Should_Not_Offer_Fix() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var publication = new KafkaPublication + { + {|#0:Partitioner = (Partitioner)Partitioner.Consistent|} + }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + + await testContext.RunAsync(); + } + + [Fact] + public async Task FixAll_Should_Replace_All_Discouraged_Values() + { + testContext.TestCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var first = new KafkaPublication + { + {|#0:Partitioner = Partitioner.Consistent|} + }; + var second = new KafkaPublication + { + {|#1:Partitioner = Partitioner.Consistent|} + }; + } + } +} +"""; + + testContext.FixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var first = new KafkaPublication + { + Partitioner = Partitioner.Murmur2 + }; + var second = new KafkaPublication + { + Partitioner = Partitioner.Murmur2 + }; + } + } +} +"""; + + testContext.BatchFixedCode = /* lang=c#-test */ + """ +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.Kafka; + +namespace ConsoleApplication1 +{ + class TypeName + { + public void Method() + { + var first = new KafkaPublication + { + Partitioner = Partitioner.Murmur2 + }; + var second = new KafkaPublication + { + Partitioner = Partitioner.Murmur2 + }; + } + } +} +"""; + + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(0) + ); + testContext.ExpectedDiagnostics.Add( + new DiagnosticResult( + KafkaPublicationPartitionerAnalyzer.ConsistentPartitionerRule + ).WithLocation(1) + ); + + await testContext.RunAsync(); + } +} diff --git a/tests/Paramore.Brighter.Analyzer.Tests/Paramore.Brighter.Analyzer.Tests.csproj b/tests/Paramore.Brighter.Analyzer.Tests/Paramore.Brighter.Analyzer.Tests.csproj index 1eef7fe76a..fcb1e9c9d3 100644 --- a/tests/Paramore.Brighter.Analyzer.Tests/Paramore.Brighter.Analyzer.Tests.csproj +++ b/tests/Paramore.Brighter.Analyzer.Tests/Paramore.Brighter.Analyzer.Tests.csproj @@ -14,12 +14,15 @@ + + +