diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eeb59f4..6b5c8f1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,8 +1,9 @@ -name: Publish to NuGet +name: Build, Tests and Publish on: push: branches: [master] + pull_request: jobs: build-and-publish: @@ -32,5 +33,12 @@ jobs: - name: Pack run: dotnet pack Collections.Analyzer/Collections.Analyzer.csproj --configuration Release --no-build --output nupkgs + - name: NuGet login (OIDC → temp API key) + uses: NuGet/login@v1 + id: login + with: + user: Backs + - name: Push to NuGet - run: dotnet nuget push nupkgs/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + run: dotnet nuget push nupkgs/*.nupkg --api-key ${{steps.login.outputs.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md index 508ba2e..0595fa8 100644 --- a/AI_CONTEXT.md +++ b/AI_CONTEXT.md @@ -41,6 +41,7 @@ This file is intended to help AI agents quickly understand the project structure ### 3. Writing Tests - Tests should be located in the `Tests` project; the folder should match the diagnostic name. - Place source code for tests in `Tests/Resources//`. +- **Important**: When adding test data to the `Tests/Resources` folder, do NOT add them to `Tests.csproj` because the entire folder is already included via a wildcard. - Use `ResourceReader.ReadFromFile()` to load the code. - Use `{|CIxxxx:code|}` syntax in resource files to mark expected diagnostics. - Verify both the presence of the diagnostic and the result of applying the fix (if applicable). diff --git a/CHANGELOG.md b/CHANGELOG.md index fd9f3d2..aa7745c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.3.0 +- Added [CI0011](https://github.com/Backs/Collections.Analyzer/blob/master/Documentation/CI0011.md): Diagnostic to detect potential N+1 Query Problems in loops and LINQ expressions. +- Added support for `.editorconfig` configuration for CI0011 (data access type substrings, method prefixes, bulk method substrings, and test method analysis). +- Improved analyzer performance by implementing configuration caching. +- Excluded static methods and bulk/batch methods from CI0011 analysis to reduce false positives. +- General documentation updates and refactorings. + ## 0.2.15 Added [CI0010](https://github.com/Backs/Collections.Analyzer/blob/master/Documentation/CI0010.md): Diagnostic to suggest using `Dictionary` for lookups in collections inside loops or LINQ chains. This optimizes performance from O(N*M) to O(N+M). diff --git a/Collections.Analyzer/AnalyzerReleases.Shipped.md b/Collections.Analyzer/AnalyzerReleases.Shipped.md index a575a25..86b065d 100644 --- a/Collections.Analyzer/AnalyzerReleases.Shipped.md +++ b/Collections.Analyzer/AnalyzerReleases.Shipped.md @@ -1,6 +1,14 @@ ; Shipped analyzer releases ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +## Release 0.3.0 + +### New Rules + + Rule ID | Category | Severity | Notes +---------|-------------|----------|--------------------------------------- + CI0011 | Performance | Warning | NPlusOneQueryAnalyzer + ## Release 0.2.15 ### New Rules diff --git a/Collections.Analyzer/Collections.Analyzer.csproj b/Collections.Analyzer/Collections.Analyzer.csproj index e6f4e45..5512c8b 100644 --- a/Collections.Analyzer/Collections.Analyzer.csproj +++ b/Collections.Analyzer/Collections.Analyzer.csproj @@ -6,7 +6,7 @@ true true true - 0.2.15 + 0.3.0 Collections.Analyzer Rogatnev Sergey Collections.Analyzer is a set of roslyn-based diagnostics for C#-projects that detect potential problems with operating different collections. diff --git a/Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs b/Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs new file mode 100644 index 0000000..9d24c00 --- /dev/null +++ b/Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Collections.Analyzer.Diagnostics; + +internal static class AnalyzerConfigHelper +{ + private static readonly ConditionalWeakTable ConfigCache = new(); + private static readonly char[] Separators = { ',' }; + + public static T GetConfig(AnalyzerOptions options, SyntaxTree syntaxTree, Func factory) + { + if (ConfigCache.TryGetValue(syntaxTree, out var cached) && cached is T typedConfig) + { + return typedConfig; + } + + var analyzerOptions = options.AnalyzerConfigOptionsProvider.GetOptions(syntaxTree); + var config = factory(analyzerOptions); + + ConfigCache.Remove(syntaxTree); + ConfigCache.Add(syntaxTree, config!); + + return config; + } + + public static string[] GetList(AnalyzerConfigOptions options, string optionName, string[] defaultValues) + { + return options.TryGetValue(optionName, out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray() + : defaultValues; + } +} diff --git a/Collections.Analyzer/Diagnostics/CI0008/ArrayContainsToHashSetDiagnostic.cs b/Collections.Analyzer/Diagnostics/CI0008/ArrayContainsToHashSetDiagnostic.cs index 0752f3c..4c24a13 100644 --- a/Collections.Analyzer/Diagnostics/CI0008/ArrayContainsToHashSetDiagnostic.cs +++ b/Collections.Analyzer/Diagnostics/CI0008/ArrayContainsToHashSetDiagnostic.cs @@ -38,9 +38,8 @@ public override void Initialize(AnalysisContext context) private static void AnalyzeLocalDeclaration(SyntaxNodeAnalysisContext context) { - var localDeclaration = (LocalDeclarationStatementSyntax)context.Node; - var minLength = GetMinArrayLength(context); + var localDeclaration = (LocalDeclarationStatementSyntax)context.Node; foreach (var variable in localDeclaration.Declaration.Variables) { @@ -50,9 +49,8 @@ private static void AnalyzeLocalDeclaration(SyntaxNodeAnalysisContext context) private static void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context) { - var fieldDeclaration = (FieldDeclarationSyntax)context.Node; - var minLength = GetMinArrayLength(context); + var fieldDeclaration = (FieldDeclarationSyntax)context.Node; foreach (var variable in fieldDeclaration.Declaration.Variables) { @@ -62,6 +60,7 @@ private static void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context) private static void AnalyzePropertyDeclaration(SyntaxNodeAnalysisContext context) { + var minLength = GetMinArrayLength(context); var propertyDeclaration = (PropertyDeclarationSyntax)context.Node; var typeInfo = context.SemanticModel.GetTypeInfo(propertyDeclaration.Type); @@ -77,7 +76,6 @@ private static void AnalyzePropertyDeclaration(SyntaxNodeAnalysisContext context return; var arraySize = CountArrayElements(arrayInitializer); - var minLength = GetMinArrayLength(context); if (arraySize < minLength) return; @@ -245,19 +243,6 @@ argument.Parent is ArgumentListSyntax argumentList && return false; } - private static int GetMinArrayLength(SyntaxNodeAnalysisContext context) - { - var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree); - - if (options.TryGetValue(MinArrayLengthOption, out var valueString) && - int.TryParse(valueString, out var value) && value >= 0) - { - return value; - } - - return DefaultMinArrayLength; - } - private static int CountArrayElements(InitializerExpressionSyntax initializer) { return initializer.Expressions.Count; @@ -269,4 +254,17 @@ private class UsageAnalysis public bool HasUnsupportedUsage { get; set; } public bool ShouldWarn => HasContainsCall && !HasUnsupportedUsage; } + private static int GetMinArrayLength(SyntaxNodeAnalysisContext context) + { + return AnalyzerConfigHelper.GetConfig(context.Options, context.Node.SyntaxTree, options => + { + if (options.TryGetValue(MinArrayLengthOption, out var valueString) && + int.TryParse(valueString, out var value) && value >= 0) + { + return value; + } + + return DefaultMinArrayLength; + }); + } } \ No newline at end of file diff --git a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs new file mode 100644 index 0000000..9a89072 --- /dev/null +++ b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Collections.Analyzer.Diagnostics.CI0011; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class NPlusOneQueryAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + internal static readonly DiagnosticDescriptor Rule = new( + "CI0011", + Resources.CI0011_Title, + Resources.CI0011_MessageFormat, + Categories.Performance, + DiagnosticSeverity.Warning, + true, + Resources.CI0011_Description + ); + + private static readonly FrozenSet LinqMethodNames = + new[] { + nameof(Enumerable.Select), + nameof(Enumerable.Where), + nameof(Enumerable.Any), + nameof(Enumerable.All), + nameof(Enumerable.Count), + nameof(Enumerable.First), + nameof(Enumerable.FirstOrDefault), + nameof(Enumerable.Single), + nameof(Enumerable.SingleOrDefault) + }.ToFrozenSet(); + + private static readonly FrozenSet TestAttributeNames = + new[] + { + "FactAttribute", "TheoryAttribute", "TestAttribute", "TestCaseAttribute", "TestFixtureAttribute", + "TestMethodAttribute", "TestClassAttribute" + }.ToFrozenSet(); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeLoopNode, + SyntaxKind.ForEachStatement, + SyntaxKind.ForStatement, + SyntaxKind.ForEachVariableStatement); + context.RegisterSyntaxNodeAction(AnalyzeLinqInvocation, SyntaxKind.InvocationExpression); + } + + private static void AnalyzeLinqInvocation(SyntaxNodeAnalysisContext context) + { + if (context.Node is not InvocationExpressionSyntax invocation) return; + + var config = GetConfig(context); + + // Skip analysis if we are inside a test method, and it's not explicitly enabled via .editorconfig + if (context.SemanticModel.GetEnclosingSymbol(invocation.SpanStart) is IMethodSymbol enclosingMethodSymbol + && IsTestMethod(enclosingMethodSymbol) + && !config.AnalyzeTestMethodsEnabled) + { + return; + } + + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) return; + + // Check if the method is one of the LINQ methods that can cause N+1 query issues when used with lambdas + var methodName = memberAccess.Name.Identifier.Text; + if (!LinqMethodNames.Contains(methodName)) return; + + if (invocation.ArgumentList.Arguments.Count == 0) return; + var argument = invocation.ArgumentList.Arguments[0]; + + // We are looking for lambda expressions passed to LINQ methods + if (argument.Expression is not LambdaExpressionSyntax lambda) return; + + var body = lambda switch + { + SimpleLambdaExpressionSyntax simple => simple.Body, + ParenthesizedLambdaExpressionSyntax parenthesized => parenthesized.Body, + _ => null + }; + + if (body == null) return; + + var lambdaParameters = GetLambdaParameters(lambda, context.SemanticModel); + if (lambdaParameters.Count == 0) return; + + var innerInvocations = body.DescendantNodesAndSelf().OfType(); + foreach (var innerInvocation in innerInvocations) + { + AnalyzeInvocation(context, innerInvocation, lambdaParameters); + } + } + + private static ISet GetLambdaParameters(LambdaExpressionSyntax lambda, SemanticModel semanticModel) + { + IEnumerable parameters = lambda switch + { + SimpleLambdaExpressionSyntax simple => new[] { simple.Parameter }, + ParenthesizedLambdaExpressionSyntax parenthesized => parenthesized.ParameterList.Parameters, + _ => Array.Empty() + }; + + return parameters + .Select(p => semanticModel.GetDeclaredSymbol(p)) + .OfType() + .ToImmutableHashSet(SymbolEqualityComparer.Default); + } + + private static void AnalyzeLoopNode(SyntaxNodeAnalysisContext context) + { + var loopNode = context.Node; + + var config = GetConfig(context); + // Skip analysis if we are inside a test method, and it's not explicitly enabled via .editorconfig + if (context.SemanticModel.GetEnclosingSymbol(loopNode.SpanStart) is IMethodSymbol enclosingMethodSymbol + && IsTestMethod(enclosingMethodSymbol) + && !config.AnalyzeTestMethodsEnabled) + { + return; + } + + // Get variables declared by the loop (e.g., 'item' in 'foreach (var item in items)') + var loopVariables = GetLoopVariableSymbols(loopNode, context.SemanticModel); + if (loopVariables.Count == 0) + { + return; + } + + // Search for all method calls within the loop body + var invocations = loopNode.DescendantNodes().OfType(); + foreach (var invocation in invocations) + { + AnalyzeInvocation(context, invocation, loopVariables); + } + } + + private static void AnalyzeInvocation( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + ISet loopVariables) + { + var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + if (methodSymbol == null) + { + return; + } + + // Check if the invocation uses any of the loop variables + if (!UsesLoopVariable(context, invocation, loopVariables)) + { + return; + } + + var config = GetConfig(context); + // If a data access method is called using a loop variable, report a diagnostic + if (IsDataAccessMethod(methodSymbol, config)) + { + var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation(), methodSymbol.Name); + context.ReportDiagnostic(diagnostic); + } + } + + private static bool UsesLoopVariable( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + ISet loopVariables) + { + foreach (var argument in invocation.ArgumentList.Arguments) + { + var dataFlowAnalysis = context.SemanticModel.AnalyzeDataFlow(argument.Expression)!; + if (!dataFlowAnalysis.Succeeded) + { + continue; + } + + if (dataFlowAnalysis.ReadInside.Any(loopVariables.Contains)) + { + return true; + } + } + + return false; + } + + private static bool IsDataAccessMethod(IMethodSymbol methodSymbol, NPlusOneQueryConfig config) + { + if (methodSymbol.IsStatic || !IsDataAccessType(methodSymbol.ContainingType, config)) + { + return false; + } + + var methodName = methodSymbol.Name; + + // Skip bulk/batch methods by name + if (config.BulkMethodSubstrings.Any(methodName.Contains)) + { + return false; + } + + // Skip methods that take a collection as a parameter (excluding string/byte[]) + if (methodSymbol.Parameters.Any(p => IsBulkParameter(p.Type))) + { + return false; + } + + return config.DataAccessMethodPrefixes.Any(methodName.StartsWith); + } + + private static bool IsBulkParameter(ITypeSymbol type) + { + if (type.SpecialType == SpecialType.System_String) + { + return false; + } + + if (type is IArrayTypeSymbol arrayType) + { + // Allow byte[] as a non-bulk parameter (often used for blobs, keys, etc.) + return arrayType.ElementType.SpecialType != SpecialType.System_Byte; + } + + // Check for generic collections (IEnumerable, List, etc.) + return type.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T || + type.AllInterfaces.Any(i => i.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T); + } + + private static bool IsDataAccessType(INamedTypeSymbol? type, NPlusOneQueryConfig config) + { + if (type == null) + { + return false; + } + + if (IsDataAccessTypeName(type.Name, config.DataAccessTypeSubstrings)) + { + return true; + } + + return type.AllInterfaces.Any(i => IsDataAccessTypeName(i.Name, config.DataAccessTypeSubstrings)); + } + + private static bool IsDataAccessTypeName(string typeName, IEnumerable substrings) + { + return !string.IsNullOrEmpty(typeName) && substrings.Any(typeName.Contains); + } + + private static ISet GetLoopVariableSymbols(SyntaxNode loopNode, SemanticModel semanticModel) + { + var symbols = new HashSet(SymbolEqualityComparer.Default); + + switch (loopNode) + { + case ForEachStatementSyntax forEachStatement: + var symbol = semanticModel.GetDeclaredSymbol(forEachStatement); + if (symbol != null) symbols.Add(symbol); + break; + + case ForStatementSyntax forStatement: + var variableDeclarator = forStatement.Declaration?.Variables.FirstOrDefault(); + if (variableDeclarator != null) + { + var variableSymbol = semanticModel.GetDeclaredSymbol(variableDeclarator); + if (variableSymbol != null) symbols.Add(variableSymbol); + } + break; + + case ForEachVariableStatementSyntax forEachVariableStatement: + if (forEachVariableStatement.Variable is DeclarationExpressionSyntax declarationExpression) + { + var designations = declarationExpression.Designation + .DescendantNodesAndSelf() + .OfType(); + + foreach (var designation in designations) + { + var declaredSymbol = semanticModel.GetDeclaredSymbol(designation); + if (declaredSymbol != null) symbols.Add(declaredSymbol); + } + } + break; + } + + return symbols; + } + + private static bool IsTestMethod(IMethodSymbol methodSymbol) + { + if (HasTestAttribute(methodSymbol.GetAttributes())) return true; + + return methodSymbol.ContainingType != null && + HasTestAttribute(methodSymbol.ContainingType.GetAttributes()); + } + + private static bool HasTestAttribute(ImmutableArray attributes) + { + return attributes.Any(attribute => + { + var name = attribute.AttributeClass?.Name; + return name != null && TestAttributeNames.Contains(name); + }); + } + + private sealed class NPlusOneQueryConfig + { + public bool AnalyzeTestMethodsEnabled { get; } + public IReadOnlyCollection DataAccessTypeSubstrings { get; } + public IReadOnlyCollection DataAccessMethodPrefixes { get; } + public IReadOnlyCollection BulkMethodSubstrings { get; } + + private static readonly string[] DataAccessTypeSubstringsDefaults = { "Repository", "Reader", "Writer", "Handler" }; + private static readonly string[] DataAccessMethodPrefixesDefaults = { "Read", "Find", "Get", "TryRead", "TryGet", "TryFind" }; + private static readonly string[] BulkMethodSubstringsDefaults = { "Batch", "Bulk", "Range" }; + + private const string AnalyzeTestMethodsOption = "dotnet_diagnostic.CI0011.analyze_test_methods"; + private const string DataAccessTypeSubstringsOption = "dotnet_diagnostic.CI0011.data_access_type_substrings"; + private const string DataAccessMethodPrefixesOption = "dotnet_diagnostic.CI0011.data_access_method_prefixes"; + private const string BulkMethodSubstringsOption = "dotnet_diagnostic.CI0011.bulk_method_substrings"; + + + private NPlusOneQueryConfig( + bool analyzeTestMethodsEnabled, + IReadOnlyCollection dataAccessTypeSubstrings, + IReadOnlyCollection dataAccessMethodPrefixes, + IReadOnlyCollection bulkMethodSubstrings) + { + AnalyzeTestMethodsEnabled = analyzeTestMethodsEnabled; + DataAccessTypeSubstrings = dataAccessTypeSubstrings; + DataAccessMethodPrefixes = dataAccessMethodPrefixes; + BulkMethodSubstrings = bulkMethodSubstrings; + } + + public static NPlusOneQueryConfig FromOptions(AnalyzerConfigOptions options) + { + var analyzeTestMethodsEnabled = options.TryGetValue(AnalyzeTestMethodsOption, out var testMethodsValue) && + bool.TryParse(testMethodsValue, out var testMethodsResult) && testMethodsResult; + + var typeSubstrings = AnalyzerConfigHelper.GetList(options, DataAccessTypeSubstringsOption, DataAccessTypeSubstringsDefaults); + var methodPrefixes = AnalyzerConfigHelper.GetList(options, DataAccessMethodPrefixesOption, DataAccessMethodPrefixesDefaults); + var bulkSubstrings = AnalyzerConfigHelper.GetList(options, BulkMethodSubstringsOption, BulkMethodSubstringsDefaults); + + return new NPlusOneQueryConfig(analyzeTestMethodsEnabled, typeSubstrings, methodPrefixes, bulkSubstrings); + } + } + + private static NPlusOneQueryConfig GetConfig(SyntaxNodeAnalysisContext context) + { + return AnalyzerConfigHelper.GetConfig(context.Options, context.Node.SyntaxTree, NPlusOneQueryConfig.FromOptions); + } +} diff --git a/Collections.Analyzer/Resources.Designer.cs b/Collections.Analyzer/Resources.Designer.cs index 82f793a..5ce44fb 100644 --- a/Collections.Analyzer/Resources.Designer.cs +++ b/Collections.Analyzer/Resources.Designer.cs @@ -182,5 +182,23 @@ internal static string CI0010_MessageFormat { return ResourceManager.GetString("CI0010_MessageFormat", resourceCulture); } } + + internal static string CI0011_Title { + get { + return ResourceManager.GetString("CI0011_Title", resourceCulture); + } + } + + internal static string CI0011_MessageFormat { + get { + return ResourceManager.GetString("CI0011_MessageFormat", resourceCulture); + } + } + + internal static string CI0011_Description { + get { + return ResourceManager.GetString("CI0011_Description", resourceCulture); + } + } } } diff --git a/Collections.Analyzer/Resources.resx b/Collections.Analyzer/Resources.resx index 66e8e16..079d5f0 100644 --- a/Collections.Analyzer/Resources.resx +++ b/Collections.Analyzer/Resources.resx @@ -92,4 +92,13 @@ Collection '{0}' is used for linear search in a loop. Consider converting it to a Dictionary for better performance + + Potential N+1 Query Problem + + + Method '{0}' is called inside a loop and may cause an N+1 query problem + + + Avoid calling data access methods inside a loop based on the loop variable, as this indicates an N+1 query performance issue. + \ No newline at end of file diff --git a/Collections.Analyzer/Resources.ru.resx b/Collections.Analyzer/Resources.ru.resx index 3454792..0519e48 100644 --- a/Collections.Analyzer/Resources.ru.resx +++ b/Collections.Analyzer/Resources.ru.resx @@ -84,4 +84,13 @@ Коллекция '{0}' используется для линейного поиска в цикле. Рассмотрите возможность преобразования её в Dictionary для повышения производительности + + Потенциальная проблема N+1 запроса + + + Метод '{0}' вызывается внутри цикла и может привести к проблеме N+1 запроса + + + Избегайте вызова методов доступа к данным внутри цикла на основе переменной цикла, так как это указывает на проблему производительности N+1 запроса. + \ No newline at end of file diff --git a/Documentation/CI0011.md b/Documentation/CI0011.md new file mode 100644 index 0000000..fe883e9 --- /dev/null +++ b/Documentation/CI0011.md @@ -0,0 +1,87 @@ +# CI0011: Potential N+1 Query Problem + +Calling data access methods inside a loop (or LINQ expressions like `Select`, `Where`, etc.) for each item in a collection can lead to severe performance issues. This pattern is known as the "[N+1 Query Problem]([https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping](https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping))" because it results in N additional database queries where a single bulk query could have been used. + +## Configuration + +The analyzer can be customized via `.editorconfig` to match your project's naming conventions and requirements. + +### Data Access Types + +By default, the analyzer looks for methods in classes where the name contains one of the following substrings: `Repository`, `Reader`, `Writer`, `Handler`. You can override this list: +```ini +dotnet_diagnostic.CI0011.data_access_type_substrings = Repository, Service, Store +``` + +### Data Access Methods + +By default, the analyzer triggers on methods starting with: `Read`, `Find`, `Get`, `TryRead`, `TryGet`, `TryFind`. You can override this list: +```ini +dotnet_diagnostic.CI0011.data_access_method_prefixes = Get, Fetch, Query +``` + +### Analyzing Test Methods + +By default, the analyzer ignores methods marked with common test attributes (e.g., `[Test]`, `[Fact]`, `[TestMethod]`). You can enable analysis for tests: +```ini +dotnet_diagnostic.CI0011.analyze_test_methods = true +``` + +### Multiple Values + +For list-based options, you must use a comma (`,`) as a separator. +```ini +dotnet_diagnostic.CI0011.data_access_method_prefixes = Get, Fetch, Query +``` + +### Static Methods + +Static methods are ignored by the analyzer because they are typically not used for data access operations that lead to the N+1 Query Problem. + +### Bulk and Batch Methods + +The analyzer excludes methods that are clearly designed for bulk operations to reduce false positives. A method is considered a bulk method if: +- Its name contains one of the configured substrings (default: `Batch`, `Bulk`, `Range`). +- It takes a collection (e.g., `IEnumerable`, `T[]`, `List`) as a parameter, provided it's not a `string` or `byte[]`. + +You can override the list of substrings: +```ini +dotnet_diagnostic.CI0011.bulk_method_substrings = Batch, Bulk, Multi, Collection +``` + +## Code Fix + +This diagnostic does not provide an automated Code Fix because the solution usually requires architectural changes, such as: +- Adding a new bulk method to the repository (e.g., `GetByIds(IEnumerable ids)`). +- Using eager loading in your ORM (e.g., `.Include()` in Entity Framework). +- Refactoring the data flow to fetch dependencies before the loop. + +The developer must choose the most appropriate strategy based on the specific context and data access layer implementation. + +## Examples + +### Incorrect +```csharp +public void ProcessOrders(IEnumerable orderIds) +{ + foreach (var id in orderIds) + { + // BUG: Each iteration performs a separate database call + var order = _orderRepository.GetById(id); + Process(order); + } +} +``` + +### Correct +```csharp +public void ProcessOrders(IEnumerable orderIds) +{ + // FIX: Fetch all data at once using a single bulk query + var orders = _orderRepository.GetByIds(orderIds); + foreach (var order in orders) + { + Process(order); + } +} +``` diff --git a/Documentation/Diagnostics.md b/Documentation/Diagnostics.md index 77d5318..0b563a4 100644 --- a/Documentation/Diagnostics.md +++ b/Documentation/Diagnostics.md @@ -9,4 +9,5 @@ | [CI0007](CI0007.md) | Method `Any()` is used on concurrent collection | Warning | Use `IsEmpty` property to check empty collection | | [CI0008](CI0008.md) | Consider using HashSet for Contains operations | Warning | Array is used with `Contains()` method. `HashSet` provides O(1) lookup performance | | [CI0009](CI0009.md) | List capacity should be set based on the source collection | Warning | Initialize `List` capacity with the size of a source collection | -| [CI0010](CI0010.md) | Consider using Dictionary for lookups in collections | Warning | Collection is used for lookups inside a loop or LINQ. Using `Dictionary` provides O(1) performance. | \ No newline at end of file +| [CI0010](CI0010.md) | Consider using Dictionary for lookups in collections | Warning | Collection is used for lookups inside a loop or LINQ. Using `Dictionary` provides O(1) performance. | +| [CI0011](CI0011.md) | Potential N+1 Query Problem | Warning | Avoid calling data access methods inside a loop based on the loop variable. | \ No newline at end of file diff --git a/README.md b/README.md index 173b164..603b414 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ For more information, see the following articles: ### Compiler warnings -Analyze your C#-code and warn about redundant method calls. +Analyze your C#-code and warn about: +- **Redundant method calls** and inefficient collection transformations. +- **N+1 Query Problems** in loops and LINQ expressions when accessing repositories or services. ![Code fix string](https://raw.githubusercontent.com/Backs/Collections.Analyzer/master/Documentation/img/string-example-2.png) @@ -35,7 +37,7 @@ Automatically fixes found problems. Every analyzer can be installed as a usual nuget-package. Just add a package reference to a project: ``` - + ``` The analyzer will work only in the project it was added to. If you want to analyse all projects in your solution, you @@ -44,7 +46,7 @@ can add file `Directory.build.props` to the solution directory with content: ``` - + ``` diff --git a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs new file mode 100644 index 0000000..b035e39 --- /dev/null +++ b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs @@ -0,0 +1,244 @@ +using System.Threading.Tasks; +using Collections.Analyzer.Diagnostics.CI0011; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using NUnit.Framework; + +namespace Tests.NPlusOneQueryTests; + +[TestFixture] +public class NPlusOneQueryTests : CSharpAnalyzerTest +{ + [Test] + public Task ForeachLoop_WithLoopVariable_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery1.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code); + } + + [Test] + public Task ForLoop_WithLoopVariable_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery2.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code); + } + + [Test] + public Task ForeachLoop_WithoutLoopVariable_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery3.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, DiagnosticResult.EmptyDiagnosticResults); + } + + [Test] + public Task ForeachLoop_VoidOrTaskMethod_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery4.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, DiagnosticResult.EmptyDiagnosticResults); + } + + [Test] + public Task ForeachLoop_CollectionMethod_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery5.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, DiagnosticResult.EmptyDiagnosticResults); + } + + [Test] + public Task ForeachLoop_WithLoopVariableProperty_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery6.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code); + } + + [Test] + public Task ForeachLoop_WithTupleDeconstruction_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery7.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code); + } + + [Test] + public Task ForeachLoop_WithAsyncMethods_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery8.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code); + } + + [Test] + public Task ForeachLoop_WithoutLoopVariableUsage_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery9.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, DiagnosticResult.EmptyDiagnosticResults); + } + + [Test] + public Task NestedLoops_WithLoopVariables_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery10.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code); + } + + [Test] + public Task ForeachLoop_WithNonRepositoryClass_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery11.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, DiagnosticResult.EmptyDiagnosticResults); + } + + [Test] + public Task LinqSelect_WithLoopVariable_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery12.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code); + } + + [Test] + public Task LinqSelect_WithBlockBody_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery13.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code); + } + + [Test] + public Task StaticMethod_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery17.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code); + } + + [Test] + public Task BatchMethod_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery18.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, DiagnosticResult.EmptyDiagnosticResults); + } + + [Test] + public Task ParameterTypes_ShouldWarnCorrectly() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery20.cs"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code); + } + + [Test] + public Task TestMethod_ByDefault_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery14.cs"); + + var test = new CSharpAnalyzerTest + { + TestCode = code, + ReferenceAssemblies = ReferenceAssemblies.Net.Net80 + }; + + // Add NUnit assembly reference + test.TestState.AdditionalReferences.Add(typeof(TestAttribute).Assembly.Location); + + return test.RunAsync(); + } + + [Test] + public Task TestMethod_WhenEnabledViaEditorConfig_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery14.cs"); + + var test = new CSharpAnalyzerTest + { + TestCode = code, + ReferenceAssemblies = ReferenceAssemblies.Net.Net80 + }; + + // Add NUnit assembly reference + test.TestState.AdditionalReferences.Add(typeof(TestAttribute).Assembly.Location); + + test.TestState.AnalyzerConfigFiles.Add( + ("/.editorconfig", $"is_global = true{System.Environment.NewLine}dotnet_diagnostic.CI0011.analyze_test_methods = true") + ); + + test.ExpectedDiagnostics.Add( + DiagnosticResult.CompilerWarning("CI0011").WithSpan(13, 24, 13, 48).WithArguments("GetData") + ); + + return test.RunAsync(); + } + + [Test] + public Task CustomTypeSubstrings_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery15.cs"); + + var test = new CSharpAnalyzerTest + { + TestCode = code, + }; + + test.TestState.AnalyzerConfigFiles.Add( + ("/.editorconfig", $"is_global = true{System.Environment.NewLine}dotnet_diagnostic.CI0011.data_access_type_substrings = Service") + ); + + return test.RunAsync(); + } + + [Test] + public Task CustomMethodPrefixes_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery16.cs"); + + var test = new CSharpAnalyzerTest + { + TestCode = code, + }; + + test.TestState.AnalyzerConfigFiles.Add( + ("/.editorconfig", $"is_global = true{System.Environment.NewLine}dotnet_diagnostic.CI0011.data_access_method_prefixes = Fetch") + ); + + return test.RunAsync(); + } + + [Test] + public Task CustomConfiguration_WithoutSetting_ShouldNotWarn() + { + var code1 = ResourceReader.ReadFromFile("NPlusOneQuery15_NoMarkup.cs"); + var code2 = ResourceReader.ReadFromFile("NPlusOneQuery16_NoMarkup.cs"); + + return Task.WhenAll( + NPlusOneQueryVerifier.VerifyAnalyzerAsync(code1, DiagnosticResult.EmptyDiagnosticResults), + NPlusOneQueryVerifier.VerifyAnalyzerAsync(code2, DiagnosticResult.EmptyDiagnosticResults) + ); + } + + [Test] + public Task CustomBulkMethodSubstrings_ShouldWarnCorrectly() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery21.cs"); + + var test = new CSharpAnalyzerTest + { + TestCode = code, + }; + + test.TestState.AnalyzerConfigFiles.Add( + ("/.editorconfig", $"is_global = true{System.Environment.NewLine}dotnet_diagnostic.CI0011.bulk_method_substrings = CustomSuffix") + ); + + return test.RunAsync(); + } +} diff --git a/Tests/NPlusOneQueryTests/NPlusOneQueryVerifier.cs b/Tests/NPlusOneQueryTests/NPlusOneQueryVerifier.cs new file mode 100644 index 0000000..a6f3c98 --- /dev/null +++ b/Tests/NPlusOneQueryTests/NPlusOneQueryVerifier.cs @@ -0,0 +1,11 @@ +using Collections.Analyzer.Diagnostics.CI0011; +using Microsoft.CodeAnalysis.Testing; + +namespace Tests.NPlusOneQueryTests; + +public class NPlusOneQueryVerifier : AnalyzerVerifier< + NPlusOneQueryAnalyzer, + NPlusOneQueryTests, + DefaultVerifier> +{ +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery1.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery1.cs new file mode 100644 index 0000000..b07b477 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery1.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +public class TestClass +{ + public void TestMethod(IEnumerable items) + { + var repository = new MyRepository(); + foreach (var item in items) + { + var data = {|CI0011:repository.GetData(item)|}; + } + } + + public class MyRepository + { + public string GetData(int id) => id.ToString(); + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery10.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery10.cs new file mode 100644 index 0000000..2d5ee45 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery10.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace Tests.Resources.NPlusOneQuery +{ + public class NPlusOneQuery10 + { + public void TestMethod(IEnumerable items, IEnumerable otherItems) + { + var repository = new MyRepository(); + foreach (var item in items) + { + foreach (var other in otherItems) + { + var data = {|CI0011:repository.GetData(item)|}; + var data2 = {|CI0011:repository.GetData(other)|}; + } + } + } + + public class MyRepository + { + public string GetData(int id) => id.ToString(); + } + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery11.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery11.cs new file mode 100644 index 0000000..593b169 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery11.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace Tests.Resources.NPlusOneQuery +{ + public class NPlusOneQuery11 + { + public void TestMethod(IEnumerable items) + { + var service = new MyService(); + foreach (var item in items) + { + var data = service.GetData(item); // Not a repository/reader/writer/handler + } + } + + public class MyService + { + public string GetData(int id) => id.ToString(); + } + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery12.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery12.cs new file mode 100644 index 0000000..ca4ac2b --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery12.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Tests.Resources.NPlusOneQuery +{ + public class NPlusOneQuery12 + { + public void TestMethod(IEnumerable items) + { + var repository = new MyRepository(); + var data = items.Select(id => {|CI0011:repository.GetData(id)|}).ToList(); + } + + public class MyRepository + { + public string GetData(int id) => id.ToString(); + } + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery13.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery13.cs new file mode 100644 index 0000000..d0bee1c --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery13.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Tests.Resources.NPlusOneQuery +{ + public class NPlusOneQuery13 + { + public void TestMethod(IEnumerable items) + { + var repository = new MyRepository(); + var data = items.Select(id => + { + var val = {|CI0011:repository.GetData(id)|}; + return val; + }).ToList(); + } + + public class MyRepository + { + public string GetData(int id) => id.ToString(); + } + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery14.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery14.cs new file mode 100644 index 0000000..0d9d7f2 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery14.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; + +public class TestClassWithAttributes +{ + [Test] + public void TestMethod(IEnumerable items) + { + var repository = new MyRepository(); + foreach (var item in items) + { + var data = repository.GetData(item); + } + } + + public class MyRepository + { + public string GetData(int id) => id.ToString(); + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery15.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery15.cs new file mode 100644 index 0000000..d09c36c --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery15.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +public class TestClass +{ + public void TestMethod(IEnumerable items) + { + var service = new MyService(); + foreach (var item in items) + { + var data = {|CI0011:service.GetData(item)|}; + } + } + + public class MyService + { + public string GetData(int id) => id.ToString(); + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery15_NoMarkup.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery15_NoMarkup.cs new file mode 100644 index 0000000..290345e --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery15_NoMarkup.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +public class TestClass +{ + public void TestMethod(IEnumerable items) + { + var service = new MyService(); + foreach (var item in items) + { + var data = service.GetData(item); + } + } + + public class MyService + { + public string GetData(int id) => id.ToString(); + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery16.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery16.cs new file mode 100644 index 0000000..1b97458 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery16.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +public class TestClass +{ + public void TestMethod(IEnumerable items) + { + var repository = new MyRepository(); + foreach (var item in items) + { + var data = {|CI0011:repository.FetchData(item)|}; + } + } + + public class MyRepository + { + public string FetchData(int id) => id.ToString(); + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery16_NoMarkup.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery16_NoMarkup.cs new file mode 100644 index 0000000..886ef18 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery16_NoMarkup.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +public class TestClass +{ + public void TestMethod(IEnumerable items) + { + var repository = new MyRepository(); + foreach (var item in items) + { + var data = repository.FetchData(item); + } + } + + public class MyRepository + { + public string FetchData(int id) => id.ToString(); + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery17.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery17.cs new file mode 100644 index 0000000..3d325ea --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery17.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Tests.Resources.NPlusOneQuery +{ + public class User + { + public int Id { get; set; } + public string Name { get; set; } + } + + public class UserRepository + { + public static User GetUserById(int id) + { + return new User { Id = id, Name = "User" + id }; + } + + public User GetUserByIdInstance(int id) + { + return new User { Id = id, Name = "User" + id }; + } + } + + public class TestClass + { + public void ProcessUsers(IEnumerable ids) + { + foreach (var id in ids) + { + // Static method should not call warning + var user = UserRepository.GetUserById(id); + Console.WriteLine(user.Name); + } + } + + public void ProcessUsersInstance(IEnumerable ids, UserRepository repo) + { + foreach (var id in ids) + { + // Instance method should call warning + var user = {|CI0011:repo.GetUserByIdInstance(id)|}; + Console.WriteLine(user.Name); + } + } + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery18.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery18.cs new file mode 100644 index 0000000..1096bd0 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery18.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Tests.Resources.NPlusOneQuery.Batch +{ + public class TradingPartnersSettings { } + + public interface ITradingPartnersSettingsRepository + { + Task ReadAllKeysAsync(); + Task ReadBatchAsync(IEnumerable ids); + } + + public class TradingPartnerService + { + private readonly ITradingPartnersSettingsRepository _repository; + + public TradingPartnerService(ITradingPartnersSettingsRepository repository) + { + _repository = repository; + } + + public async Task ProcessInLoop(IEnumerable allIds) + { + foreach (var id in allIds) + { + // Batch method should not call warning + var settings = await _repository.ReadBatchAsync(new[] { id }); + Console.WriteLine(settings.Length); + } + } + + public async Task ProcessInBatches(IEnumerable> batches) + { + foreach (var batch in batches) + { + // This should NOT be a warning because we are processing data in batches. + var settings = await _repository.ReadBatchAsync(batch); + Console.WriteLine(settings.Length); + } + } + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery2.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery2.cs new file mode 100644 index 0000000..0a9885c --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery2.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +public class TestClass +{ + public void TestMethod(int[] items) + { + var repository = new MyRepository(); + for (int i = 0; i < items.Length; i++) + { + var data = {|CI0011:repository.GetData(i)|}; + } + } + + public class MyRepository + { + public string GetData(int id) => id.ToString(); + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery20.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery20.cs new file mode 100644 index 0000000..eda0098 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery20.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Tests.Resources.NPlusOneQuery.ParameterTypes +{ + public class User { } + + public class UserRepository + { + public User GetByName(string name) => null; + public User GetByData(byte[] data) => null; + public User GetByTags(string[] tags) => null; + } + + public class UserService + { + private readonly UserRepository _repo = new UserRepository(); + + public void Process(IEnumerable names, IEnumerable datas, IEnumerable tagsList) + { + foreach (var name in names) + { + // string is not considered a bulk parameter, SHOULD be a warning + {|CI0011:_repo.GetByName(name)|}; + } + + foreach (var data in datas) + { + // byte[] is not considered a bulk parameter, SHOULD be a warning + {|CI0011:_repo.GetByData(data)|}; + } + + foreach (var tags in tagsList) + { + // string[] is considered a bulk parameter, SHOULD NOT be a warning + _repo.GetByTags(tags); + } + } + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery21.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery21.cs new file mode 100644 index 0000000..190b679 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery21.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Tests.Resources.NPlusOneQuery +{ + public class User { } + + public class UserRepository + { + public User ReadWithCustomSuffix(int id) => null; + public User ReadBatch(int id) => null; + } + + public class TestClass + { + public void Process(IEnumerable ids, UserRepository repo) + { + foreach (var id in ids) + { + // If CustomSuffix is configured as bulk in config, there should be NO warning here + repo.ReadWithCustomSuffix(id); + + // If Batch is NOT in config, there SHOULD be a warning here (as Read is a data access prefix) + {|CI0011:repo.ReadBatch(id)|}; + } + } + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery3.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery3.cs new file mode 100644 index 0000000..6fc4c5a --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery3.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +public class TestClass +{ + public void TestMethod(IEnumerable items) + { + int externalId = 10; + foreach (var item in items) + { + var data = GetData(externalId); + } + } + + private string GetData(int id) => id.ToString(); +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery4.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery4.cs new file mode 100644 index 0000000..e3a5ab8 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery4.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +public class TestClass +{ + public void TestMethod(IEnumerable items) + { + foreach (var item in items) + { + Process(item); + } + } + + private void Process(int id) { } + + public async Task TestMethodAsync(IEnumerable items) + { + foreach (var item in items) + { + await ProcessAsync(item); + } + } + + private Task ProcessAsync(int id) => Task.CompletedTask; +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery5.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery5.cs new file mode 100644 index 0000000..06f593d --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery5.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +public class TestClass +{ + public void TestMethod(IEnumerable items) + { + foreach (var item in items) + { + var data = GetList(item); + } + } + + private List GetList(int id) => new List(); +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery6.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery6.cs new file mode 100644 index 0000000..6664435 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery6.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; + +public class User +{ + public int Id { get; set; } +} + +public class TestClass +{ + public void TestMethod(IEnumerable users) + { + var repository = new MyRepository(); + foreach (var user in users) + { + var data = {|CI0011:repository.GetData(user.Id)|}; + } + } + + public class MyRepository + { + public string GetData(int id) => id.ToString(); + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery7.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery7.cs new file mode 100644 index 0000000..90c446f --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery7.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace Tests.Resources.NPlusOneQuery +{ + public class NPlusOneQuery7 + { + public void TestMethod(IEnumerable<(int Id, string Name)> items) + { + var repository = new MyRepository(); + foreach (var (id, name) in items) + { + var data = {|CI0011:repository.GetData(id)|}; + } + } + + public class MyRepository + { + public string GetData(int id) => id.ToString(); + } + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery8.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery8.cs new file mode 100644 index 0000000..3313d40 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery8.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Tests.Resources.NPlusOneQuery +{ + public class NPlusOneQuery8 + { + public async Task TestMethod(IEnumerable items) + { + var repository = new MyRepository(); + foreach (var item in items) + { + var data = await {|CI0011:repository.GetAsync(item)|}; + var data2 = await {|CI0011:repository.FindAsync(item)|}; + } + } + + public class MyRepository + { + public Task GetAsync(int id) => Task.FromResult(id.ToString()); + public Task FindAsync(int id) => Task.FromResult(id.ToString()); + } + } +} diff --git a/Tests/Resources/NPlusOneQuery/NPlusOneQuery9.cs b/Tests/Resources/NPlusOneQuery/NPlusOneQuery9.cs new file mode 100644 index 0000000..8d53d06 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery9.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace Tests.Resources.NPlusOneQuery +{ + public class NPlusOneQuery9 + { + public void TestMethod(IEnumerable items) + { + var repository = new MyRepository(); + foreach (var item in items) + { + var data = repository.GetData(10); // Not using loop variable + } + } + + public class MyRepository + { + public string GetData(int id) => id.ToString(); + } + } +}