From 1780b23f794c93c645c475fbe6042725cae5dfcf Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Sat, 18 Jul 2026 09:55:49 +0700 Subject: [PATCH 01/11] tests --- .../CI0010/NPlusOneQueryAnalyzer.cs | 189 ++++++++++++++++++ .../NPlusOneQueryTests/NPlusOneQueryTests.cs | 62 ++++++ .../NPlusOneQueryVerifier.cs | 11 + Tests/Resources/NPlusOneQuery1.txt | 15 ++ Tests/Resources/NPlusOneQuery2.txt | 15 ++ Tests/Resources/NPlusOneQuery3.txt | 16 ++ Tests/Resources/NPlusOneQuery4.txt | 26 +++ Tests/Resources/NPlusOneQuery5.txt | 15 ++ Tests/Resources/NPlusOneQuery6.txt | 20 ++ 9 files changed, 369 insertions(+) create mode 100644 Collections.Analyzer/Diagnostics/CI0010/NPlusOneQueryAnalyzer.cs create mode 100644 Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs create mode 100644 Tests/NPlusOneQueryTests/NPlusOneQueryVerifier.cs create mode 100644 Tests/Resources/NPlusOneQuery1.txt create mode 100644 Tests/Resources/NPlusOneQuery2.txt create mode 100644 Tests/Resources/NPlusOneQuery3.txt create mode 100644 Tests/Resources/NPlusOneQuery4.txt create mode 100644 Tests/Resources/NPlusOneQuery5.txt create mode 100644 Tests/Resources/NPlusOneQuery6.txt diff --git a/Collections.Analyzer/Diagnostics/CI0010/NPlusOneQueryAnalyzer.cs b/Collections.Analyzer/Diagnostics/CI0010/NPlusOneQueryAnalyzer.cs new file mode 100644 index 0000000..502280a --- /dev/null +++ b/Collections.Analyzer/Diagnostics/CI0010/NPlusOneQueryAnalyzer.cs @@ -0,0 +1,189 @@ +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.CI0010 +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class NPlusOneQueryAnalyzer : DiagnosticAnalyzer + { + public const string DiagnosticId = "CI0010"; // Замените на нужный ID в проекте + + private static readonly LocalizableString Title = "Potential N+1 Query Problem"; + private static readonly LocalizableString MessageFormat = "Method '{0}' is called inside a loop and may cause an N+1 query problem"; + private static readonly LocalizableString Description = "Avoid calling data access methods inside a loop based on the loop variable, as this indicates an N+1 query performance issue."; + private const string Category = "Performance"; + + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + // Регистрируем анализ на циклы foreach, for и кортежные foreach + context.RegisterSyntaxNodeAction(AnalyzeLoopNode, + SyntaxKind.ForEachStatement, + SyntaxKind.ForStatement, + SyntaxKind.ForEachVariableStatement); + } + + private void AnalyzeLoopNode(SyntaxNodeAnalysisContext context) + { + var loopNode = context.Node; + + // 1. Игнорируем тестовые методы + var enclosingMethodSymbol = context.SemanticModel.GetEnclosingSymbol(loopNode.SpanStart) as IMethodSymbol; + if (enclosingMethodSymbol != null && IsTestMethod(enclosingMethodSymbol)) + { + return; + } + + // 2. Получаем переменные цикла (поддерживает кортежи) + var loopVariables = GetLoopVariableSymbols(loopNode, context.SemanticModel); + if (loopVariables.Count == 0) return; + + var invocationExpressions = loopNode.DescendantNodes().OfType(); + + foreach (var invocation in invocationExpressions) + { + var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + if (methodSymbol == null) continue; + + // 3. Ищем любую из переменных цикла в аргументах вызываемого метода + var usesLoopVariable = false; + foreach (var argument in invocation.ArgumentList.Arguments) + { + var dataFlowAnalysis = context.SemanticModel.AnalyzeDataFlow(argument.Expression); + if (!dataFlowAnalysis.Succeeded) continue; + + if (dataFlowAnalysis.ReadInside.Any(symbol => + loopVariables.Contains(symbol, SymbolEqualityComparer.Default))) + { + usesLoopVariable = true; + break; + } + } + + if (!usesLoopVariable) continue; + + // 4. ПРАВИЛО 1: Тип класса или интерфейса должен иметь целевой суффикс + bool isDataAccessType = false; + var containingType = methodSymbol.ContainingType; + + if (containingType != null) + { + // Проверяем само имя типа + if (IsDataAccessTypeName(containingType.Name)) + { + isDataAccessType = true; + } + // Проверяем все реализуемые интерфейсы + else if (containingType.AllInterfaces.Any(i => IsDataAccessTypeName(i.Name))) + { + isDataAccessType = true; + } + } + + if (!isDataAccessType) continue; + + // 5. ПРАВИЛО 2: Методы Read, Find, Get (включая асинхронные версии) + var methodName = methodSymbol.Name; + + var isTargetMethod = methodName.StartsWith("Read") || + methodName.StartsWith("Find") || + methodName.StartsWith("Get"); + + if (!isTargetMethod) continue; + + // 6. Репортим ошибку (ПРАВИЛО 4 - внутри цикла) + var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation(), methodSymbol.Name); + context.ReportDiagnostic(diagnostic); + } + } + + private static bool IsDataAccessTypeName(string typeName) + { + if (string.IsNullOrEmpty(typeName)) return false; + + return typeName.EndsWith("Repository") || + typeName.EndsWith("Reader") || + typeName.EndsWith("Writer") || + typeName.EndsWith("Handler"); + } + + private static IReadOnlyCollection 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 symbol = semanticModel.GetDeclaredSymbol(variableDeclarator); + if (symbol != null) symbols.Add(symbol); + } + + break; + } + case ForEachVariableStatementSyntax forEachVariableStatement: + { + // Поддержка деконструкции: foreach (var (a, b) in items) + if (forEachVariableStatement.Variable is DeclarationExpressionSyntax declarationExpression) + { + // Рекурсивно собираем все SingleVariableDesignationSyntax + var designations = declarationExpression.Designation + .DescendantNodesAndSelf() + .OfType(); + + symbols.UnionWith(designations.Select(designation => semanticModel.GetDeclaredSymbol(designation)).OfType()); + } + + 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) + { + foreach (var attribute in attributes) + { + var attributeName = attribute.AttributeClass?.Name; + if (string.IsNullOrEmpty(attributeName)) continue; + + if (attributeName is "FactAttribute" or "TheoryAttribute" or "TestAttribute" or "TestCaseAttribute" or "TestFixtureAttribute" or "TestMethodAttribute" or "TestClassAttribute") + { + return true; + } + } + + return false; + } + } +} \ No newline at end of file diff --git a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs new file mode 100644 index 0000000..8a9537c --- /dev/null +++ b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs @@ -0,0 +1,62 @@ +using System.Threading.Tasks; +using Collections.Analyzer.Diagnostics.CI0010; +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.txt"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, + DiagnosticResult.CompilerWarning("CI0010").WithSpan(10, 24, 10, 37).WithArguments("GetData")); + } + + [Test] + public Task ForLoop_WithLoopVariable_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery2.txt"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, + DiagnosticResult.CompilerWarning("CI0010").WithSpan(10, 24, 10, 34).WithArguments("GetData")); + } + + [Test] + public Task ForeachLoop_WithoutLoopVariable_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery3.txt"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, DiagnosticResult.EmptyDiagnosticResults); + } + + [Test] + public Task ForeachLoop_VoidOrTaskMethod_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery4.txt"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, DiagnosticResult.EmptyDiagnosticResults); + } + + [Test] + public Task ForeachLoop_CollectionMethod_ShouldNotWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery5.txt"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, DiagnosticResult.EmptyDiagnosticResults); + } + + [Test] + public Task ForeachLoop_WithLoopVariableProperty_ShouldWarn() + { + var code = ResourceReader.ReadFromFile("NPlusOneQuery6.txt"); + + return NPlusOneQueryVerifier.VerifyAnalyzerAsync(code, + DiagnosticResult.CompilerWarning("CI0010").WithSpan(15, 24, 15, 40).WithArguments("GetData")); + } +} diff --git a/Tests/NPlusOneQueryTests/NPlusOneQueryVerifier.cs b/Tests/NPlusOneQueryTests/NPlusOneQueryVerifier.cs new file mode 100644 index 0000000..ddf0ba3 --- /dev/null +++ b/Tests/NPlusOneQueryTests/NPlusOneQueryVerifier.cs @@ -0,0 +1,11 @@ +using Collections.Analyzer.Diagnostics.CI0010; +using Microsoft.CodeAnalysis.Testing; + +namespace Tests.NPlusOneQueryTests; + +public class NPlusOneQueryVerifier : AnalyzerVerifier< + NPlusOneQueryAnalyzer, + NPlusOneQueryTests, + DefaultVerifier> +{ +} diff --git a/Tests/Resources/NPlusOneQuery1.txt b/Tests/Resources/NPlusOneQuery1.txt new file mode 100644 index 0000000..8499de3 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery1.txt @@ -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 = GetData(item); + } + } + + private string GetData(int id) => id.ToString(); +} diff --git a/Tests/Resources/NPlusOneQuery2.txt b/Tests/Resources/NPlusOneQuery2.txt new file mode 100644 index 0000000..d8a9e0d --- /dev/null +++ b/Tests/Resources/NPlusOneQuery2.txt @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +public class TestClass +{ + public void TestMethod(int[] items) + { + for (int i = 0; i < items.Length; i++) + { + var data = GetData(i); + } + } + + private string GetData(int id) => id.ToString(); +} diff --git a/Tests/Resources/NPlusOneQuery3.txt b/Tests/Resources/NPlusOneQuery3.txt new file mode 100644 index 0000000..6fc4c5a --- /dev/null +++ b/Tests/Resources/NPlusOneQuery3.txt @@ -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/NPlusOneQuery4.txt b/Tests/Resources/NPlusOneQuery4.txt new file mode 100644 index 0000000..e3a5ab8 --- /dev/null +++ b/Tests/Resources/NPlusOneQuery4.txt @@ -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/NPlusOneQuery5.txt b/Tests/Resources/NPlusOneQuery5.txt new file mode 100644 index 0000000..06f593d --- /dev/null +++ b/Tests/Resources/NPlusOneQuery5.txt @@ -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/NPlusOneQuery6.txt b/Tests/Resources/NPlusOneQuery6.txt new file mode 100644 index 0000000..914280d --- /dev/null +++ b/Tests/Resources/NPlusOneQuery6.txt @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +public class User +{ + public int Id { get; set; } +} + +public class TestClass +{ + public void TestMethod(IEnumerable users) + { + foreach (var user in users) + { + var data = GetData(user.Id); + } + } + + private string GetData(int id) => id.ToString(); +} From ea40f96a374b956e7155c5c4aef76ee448c8a335 Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Wed, 5 Aug 2026 15:09:39 +0700 Subject: [PATCH 02/11] tests, linq queries --- .../CI0010/NPlusOneQueryAnalyzer.cs | 189 -------------- .../CI0011/NPlusOneQueryAnalyzer.cs | 246 ++++++++++++++++++ Collections.Analyzer/Resources.Designer.cs | 18 ++ Collections.Analyzer/Resources.resx | 9 + Collections.Analyzer/Resources.ru.resx | 9 + .../NPlusOneQueryTests/NPlusOneQueryTests.cs | 79 +++++- .../NPlusOneQueryVerifier.cs | 2 +- .../Resources/NPlusOneQuery/NPlusOneQuery1.cs | 19 ++ .../NPlusOneQuery/NPlusOneQuery10.cs | 26 ++ .../NPlusOneQuery/NPlusOneQuery11.cs | 22 ++ .../NPlusOneQuery/NPlusOneQuery12.cs | 20 ++ .../NPlusOneQuery/NPlusOneQuery13.cs | 24 ++ .../Resources/NPlusOneQuery/NPlusOneQuery2.cs | 19 ++ .../NPlusOneQuery3.cs} | 0 .../NPlusOneQuery4.cs} | 0 .../NPlusOneQuery5.cs} | 0 .../NPlusOneQuery6.cs} | 8 +- .../Resources/NPlusOneQuery/NPlusOneQuery7.cs | 22 ++ .../Resources/NPlusOneQuery/NPlusOneQuery8.cs | 25 ++ .../Resources/NPlusOneQuery/NPlusOneQuery9.cs | 22 ++ Tests/Resources/NPlusOneQuery1.txt | 15 -- Tests/Resources/NPlusOneQuery2.txt | 15 -- 22 files changed, 554 insertions(+), 235 deletions(-) delete mode 100644 Collections.Analyzer/Diagnostics/CI0010/NPlusOneQueryAnalyzer.cs create mode 100644 Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery1.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery10.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery11.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery12.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery13.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery2.cs rename Tests/Resources/{NPlusOneQuery3.txt => NPlusOneQuery/NPlusOneQuery3.cs} (100%) rename Tests/Resources/{NPlusOneQuery4.txt => NPlusOneQuery/NPlusOneQuery4.cs} (100%) rename Tests/Resources/{NPlusOneQuery5.txt => NPlusOneQuery/NPlusOneQuery5.cs} (100%) rename Tests/Resources/{NPlusOneQuery6.txt => NPlusOneQuery/NPlusOneQuery6.cs} (55%) create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery7.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery8.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery9.cs delete mode 100644 Tests/Resources/NPlusOneQuery1.txt delete mode 100644 Tests/Resources/NPlusOneQuery2.txt diff --git a/Collections.Analyzer/Diagnostics/CI0010/NPlusOneQueryAnalyzer.cs b/Collections.Analyzer/Diagnostics/CI0010/NPlusOneQueryAnalyzer.cs deleted file mode 100644 index 502280a..0000000 --- a/Collections.Analyzer/Diagnostics/CI0010/NPlusOneQueryAnalyzer.cs +++ /dev/null @@ -1,189 +0,0 @@ -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.CI0010 -{ - [DiagnosticAnalyzer(LanguageNames.CSharp)] - public class NPlusOneQueryAnalyzer : DiagnosticAnalyzer - { - public const string DiagnosticId = "CI0010"; // Замените на нужный ID в проекте - - private static readonly LocalizableString Title = "Potential N+1 Query Problem"; - private static readonly LocalizableString MessageFormat = "Method '{0}' is called inside a loop and may cause an N+1 query problem"; - private static readonly LocalizableString Description = "Avoid calling data access methods inside a loop based on the loop variable, as this indicates an N+1 query performance issue."; - private const string Category = "Performance"; - - private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( - DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description); - - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); - - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.EnableConcurrentExecution(); - - // Регистрируем анализ на циклы foreach, for и кортежные foreach - context.RegisterSyntaxNodeAction(AnalyzeLoopNode, - SyntaxKind.ForEachStatement, - SyntaxKind.ForStatement, - SyntaxKind.ForEachVariableStatement); - } - - private void AnalyzeLoopNode(SyntaxNodeAnalysisContext context) - { - var loopNode = context.Node; - - // 1. Игнорируем тестовые методы - var enclosingMethodSymbol = context.SemanticModel.GetEnclosingSymbol(loopNode.SpanStart) as IMethodSymbol; - if (enclosingMethodSymbol != null && IsTestMethod(enclosingMethodSymbol)) - { - return; - } - - // 2. Получаем переменные цикла (поддерживает кортежи) - var loopVariables = GetLoopVariableSymbols(loopNode, context.SemanticModel); - if (loopVariables.Count == 0) return; - - var invocationExpressions = loopNode.DescendantNodes().OfType(); - - foreach (var invocation in invocationExpressions) - { - var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; - if (methodSymbol == null) continue; - - // 3. Ищем любую из переменных цикла в аргументах вызываемого метода - var usesLoopVariable = false; - foreach (var argument in invocation.ArgumentList.Arguments) - { - var dataFlowAnalysis = context.SemanticModel.AnalyzeDataFlow(argument.Expression); - if (!dataFlowAnalysis.Succeeded) continue; - - if (dataFlowAnalysis.ReadInside.Any(symbol => - loopVariables.Contains(symbol, SymbolEqualityComparer.Default))) - { - usesLoopVariable = true; - break; - } - } - - if (!usesLoopVariable) continue; - - // 4. ПРАВИЛО 1: Тип класса или интерфейса должен иметь целевой суффикс - bool isDataAccessType = false; - var containingType = methodSymbol.ContainingType; - - if (containingType != null) - { - // Проверяем само имя типа - if (IsDataAccessTypeName(containingType.Name)) - { - isDataAccessType = true; - } - // Проверяем все реализуемые интерфейсы - else if (containingType.AllInterfaces.Any(i => IsDataAccessTypeName(i.Name))) - { - isDataAccessType = true; - } - } - - if (!isDataAccessType) continue; - - // 5. ПРАВИЛО 2: Методы Read, Find, Get (включая асинхронные версии) - var methodName = methodSymbol.Name; - - var isTargetMethod = methodName.StartsWith("Read") || - methodName.StartsWith("Find") || - methodName.StartsWith("Get"); - - if (!isTargetMethod) continue; - - // 6. Репортим ошибку (ПРАВИЛО 4 - внутри цикла) - var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation(), methodSymbol.Name); - context.ReportDiagnostic(diagnostic); - } - } - - private static bool IsDataAccessTypeName(string typeName) - { - if (string.IsNullOrEmpty(typeName)) return false; - - return typeName.EndsWith("Repository") || - typeName.EndsWith("Reader") || - typeName.EndsWith("Writer") || - typeName.EndsWith("Handler"); - } - - private static IReadOnlyCollection 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 symbol = semanticModel.GetDeclaredSymbol(variableDeclarator); - if (symbol != null) symbols.Add(symbol); - } - - break; - } - case ForEachVariableStatementSyntax forEachVariableStatement: - { - // Поддержка деконструкции: foreach (var (a, b) in items) - if (forEachVariableStatement.Variable is DeclarationExpressionSyntax declarationExpression) - { - // Рекурсивно собираем все SingleVariableDesignationSyntax - var designations = declarationExpression.Designation - .DescendantNodesAndSelf() - .OfType(); - - symbols.UnionWith(designations.Select(designation => semanticModel.GetDeclaredSymbol(designation)).OfType()); - } - - 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) - { - foreach (var attribute in attributes) - { - var attributeName = attribute.AttributeClass?.Name; - if (string.IsNullOrEmpty(attributeName)) continue; - - if (attributeName is "FactAttribute" or "TheoryAttribute" or "TestAttribute" or "TestCaseAttribute" or "TestFixtureAttribute" or "TestMethodAttribute" or "TestClassAttribute") - { - return true; - } - } - - return false; - } - } -} \ 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..4ccc6ea --- /dev/null +++ b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs @@ -0,0 +1,246 @@ +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 + ); + + 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; + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) return; + + var methodName = memberAccess.Name.Identifier.Text; + if (methodName is not ("Select" or "Where" or "Any" or "All" or "Count" or "First" or "FirstOrDefault" or "Single" or "SingleOrDefault")) return; + + if (invocation.ArgumentList.Arguments.Count == 0) return; + var argument = invocation.ArgumentList.Arguments[0]; + + 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 IReadOnlyCollection GetLambdaParameters(LambdaExpressionSyntax lambda, SemanticModel semanticModel) + { + var parameters = lambda switch + { + SimpleLambdaExpressionSyntax simple => new[] { simple.Parameter }, + ParenthesizedLambdaExpressionSyntax parenthesized => parenthesized.ParameterList.Parameters.ToArray(), + _ => System.Array.Empty() + }; + + return parameters + .Select(p => semanticModel.GetDeclaredSymbol(p)) + .OfType() + .ToList(); + } + + private static void AnalyzeLoopNode(SyntaxNodeAnalysisContext context) + { + var loopNode = context.Node; + + var enclosingMethodSymbol = context.SemanticModel.GetEnclosingSymbol(loopNode.SpanStart) as IMethodSymbol; + if (enclosingMethodSymbol != null && IsTestMethod(enclosingMethodSymbol)) + { + return; + } + + var loopVariables = GetLoopVariableSymbols(loopNode, context.SemanticModel); + if (loopVariables.Count == 0) + { + return; + } + + var invocations = loopNode.DescendantNodes().OfType(); + foreach (var invocation in invocations) + { + AnalyzeInvocation(context, invocation, loopVariables); + } + } + + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation, IReadOnlyCollection loopVariables) + { + var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; + if (methodSymbol == null) + { + return; + } + + if (!UsesLoopVariable(context, invocation, loopVariables)) + { + return; + } + + if (IsDataAccessMethod(methodSymbol)) + { + var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation(), methodSymbol.Name); + context.ReportDiagnostic(diagnostic); + } + } + + private static bool UsesLoopVariable(SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation, IReadOnlyCollection loopVariables) + { + foreach (var argument in invocation.ArgumentList.Arguments) + { + var dataFlowAnalysis = context.SemanticModel.AnalyzeDataFlow(argument.Expression); + if (!dataFlowAnalysis.Succeeded) + { + continue; + } + + if (dataFlowAnalysis.ReadInside.Any(symbol => loopVariables.Contains(symbol, SymbolEqualityComparer.Default))) + { + return true; + } + } + + return false; + } + + private static bool IsDataAccessMethod(IMethodSymbol methodSymbol) + { + if (!IsDataAccessType(methodSymbol.ContainingType)) + { + return false; + } + + var methodName = methodSymbol.Name; + return methodName.StartsWith("Read") || + methodName.StartsWith("Find") || + methodName.StartsWith("Get") || + methodName.Contains("TryRead") || + methodName.Contains("TryGet") || + methodName.Contains("TryFind"); + } + + private static bool IsDataAccessType(INamedTypeSymbol? type) + { + if (type == null) + { + return false; + } + + if (IsDataAccessTypeName(type.Name)) + { + return true; + } + + return type.AllInterfaces.Any(i => IsDataAccessTypeName(i.Name)); + } + + private static bool IsDataAccessTypeName(string typeName) + { + if (string.IsNullOrEmpty(typeName)) + { + return false; + } + + return typeName.Contains("Repository") || + typeName.Contains("Reader") || + typeName.Contains("Writer") || + typeName.Contains("Handler"); + } + + private static IReadOnlyCollection 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 is "FactAttribute" or "TheoryAttribute" or "TestAttribute" or "TestCaseAttribute" or "TestFixtureAttribute" or "TestMethodAttribute" or "TestClassAttribute"; + }); + } +} 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/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs index 8a9537c..7916090 100644 --- a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs +++ b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs @@ -1,5 +1,5 @@ using System.Threading.Tasks; -using Collections.Analyzer.Diagnostics.CI0010; +using Collections.Analyzer.Diagnostics.CI0011; using Microsoft.CodeAnalysis.CSharp.Testing; using Microsoft.CodeAnalysis.Testing; using NUnit.Framework; @@ -12,25 +12,23 @@ public class NPlusOneQueryTests : CSharpAnalyzerTest 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/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/NPlusOneQuery3.txt b/Tests/Resources/NPlusOneQuery/NPlusOneQuery3.cs similarity index 100% rename from Tests/Resources/NPlusOneQuery3.txt rename to Tests/Resources/NPlusOneQuery/NPlusOneQuery3.cs diff --git a/Tests/Resources/NPlusOneQuery4.txt b/Tests/Resources/NPlusOneQuery/NPlusOneQuery4.cs similarity index 100% rename from Tests/Resources/NPlusOneQuery4.txt rename to Tests/Resources/NPlusOneQuery/NPlusOneQuery4.cs diff --git a/Tests/Resources/NPlusOneQuery5.txt b/Tests/Resources/NPlusOneQuery/NPlusOneQuery5.cs similarity index 100% rename from Tests/Resources/NPlusOneQuery5.txt rename to Tests/Resources/NPlusOneQuery/NPlusOneQuery5.cs diff --git a/Tests/Resources/NPlusOneQuery6.txt b/Tests/Resources/NPlusOneQuery/NPlusOneQuery6.cs similarity index 55% rename from Tests/Resources/NPlusOneQuery6.txt rename to Tests/Resources/NPlusOneQuery/NPlusOneQuery6.cs index 914280d..6664435 100644 --- a/Tests/Resources/NPlusOneQuery6.txt +++ b/Tests/Resources/NPlusOneQuery/NPlusOneQuery6.cs @@ -10,11 +10,15 @@ public class TestClass { public void TestMethod(IEnumerable users) { + var repository = new MyRepository(); foreach (var user in users) { - var data = GetData(user.Id); + var data = {|CI0011:repository.GetData(user.Id)|}; } } - private string GetData(int id) => id.ToString(); + 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(); + } + } +} diff --git a/Tests/Resources/NPlusOneQuery1.txt b/Tests/Resources/NPlusOneQuery1.txt deleted file mode 100644 index 8499de3..0000000 --- a/Tests/Resources/NPlusOneQuery1.txt +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; - -public class TestClass -{ - public void TestMethod(IEnumerable items) - { - foreach (var item in items) - { - var data = GetData(item); - } - } - - private string GetData(int id) => id.ToString(); -} diff --git a/Tests/Resources/NPlusOneQuery2.txt b/Tests/Resources/NPlusOneQuery2.txt deleted file mode 100644 index d8a9e0d..0000000 --- a/Tests/Resources/NPlusOneQuery2.txt +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; - -public class TestClass -{ - public void TestMethod(int[] items) - { - for (int i = 0; i < items.Length; i++) - { - var data = GetData(i); - } - } - - private string GetData(int id) => id.ToString(); -} From e13b12326935df76666396497f1cf9ea25f55fe3 Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Wed, 5 Aug 2026 15:54:57 +0700 Subject: [PATCH 03/11] refactoring --- .../CI0011/NPlusOneQueryAnalyzer.cs | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs index 4ccc6ea..56046f6 100644 --- a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs +++ b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -23,6 +24,28 @@ public sealed class NPlusOneQueryAnalyzer : DiagnosticAnalyzer Resources.CI0011_Description ); + private static readonly string[] DataAccessTypeSuffixes = { "Repository", "Reader", "Writer", "Handler" }; + private static readonly string[] DataAccessMethodPrefixes = { "Read", "Find", "Get", "TryRead", "TryGet", "TryFind" }; + + 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); @@ -40,12 +63,14 @@ private static void AnalyzeLinqInvocation(SyntaxNodeAnalysisContext context) if (context.Node is not InvocationExpressionSyntax invocation) 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 (methodName is not ("Select" or "Where" or "Any" or "All" or "Count" or "First" or "FirstOrDefault" or "Single" or "SingleOrDefault")) return; + 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 @@ -86,18 +111,21 @@ private static void AnalyzeLoopNode(SyntaxNodeAnalysisContext context) { var loopNode = context.Node; - var enclosingMethodSymbol = context.SemanticModel.GetEnclosingSymbol(loopNode.SpanStart) as IMethodSymbol; - if (enclosingMethodSymbol != null && IsTestMethod(enclosingMethodSymbol)) + // Skip analysis if we are inside a test method to avoid false positives in tests + if (context.SemanticModel.GetEnclosingSymbol(loopNode.SpanStart) is IMethodSymbol enclosingMethodSymbol + && IsTestMethod(enclosingMethodSymbol)) { 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) { @@ -113,11 +141,13 @@ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, Invocat return; } + // Check if the invocation uses any of the loop variables if (!UsesLoopVariable(context, invocation, loopVariables)) { return; } + // If a data access method is called using a loop variable, report a diagnostic if (IsDataAccessMethod(methodSymbol)) { var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation(), methodSymbol.Name); @@ -152,12 +182,7 @@ private static bool IsDataAccessMethod(IMethodSymbol methodSymbol) } var methodName = methodSymbol.Name; - return methodName.StartsWith("Read") || - methodName.StartsWith("Find") || - methodName.StartsWith("Get") || - methodName.Contains("TryRead") || - methodName.Contains("TryGet") || - methodName.Contains("TryFind"); + return DataAccessMethodPrefixes.Any(methodName.StartsWith); } private static bool IsDataAccessType(INamedTypeSymbol? type) @@ -177,15 +202,7 @@ private static bool IsDataAccessType(INamedTypeSymbol? type) private static bool IsDataAccessTypeName(string typeName) { - if (string.IsNullOrEmpty(typeName)) - { - return false; - } - - return typeName.Contains("Repository") || - typeName.Contains("Reader") || - typeName.Contains("Writer") || - typeName.Contains("Handler"); + return !string.IsNullOrEmpty(typeName) && DataAccessTypeSuffixes.Any(typeName.Contains); } private static IReadOnlyCollection GetLoopVariableSymbols(SyntaxNode loopNode, SemanticModel semanticModel) @@ -240,7 +257,7 @@ private static bool HasTestAttribute(ImmutableArray attributes) return attributes.Any(attribute => { var name = attribute.AttributeClass?.Name; - return name is "FactAttribute" or "TheoryAttribute" or "TestAttribute" or "TestCaseAttribute" or "TestFixtureAttribute" or "TestMethodAttribute" or "TestClassAttribute"; + return name != null && TestAttributeNames.Contains(name); }); } } From 1dc14a33df937dd6410cadf200ee01428c51dfc5 Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Wed, 5 Aug 2026 16:03:21 +0700 Subject: [PATCH 04/11] support test method option --- AI_CONTEXT.md | 1 + .../CI0011/NPlusOneQueryAnalyzer.cs | 23 +++++++++- .../NPlusOneQueryTests/NPlusOneQueryTests.cs | 42 +++++++++++++++++++ .../NPlusOneQuery/NPlusOneQuery14.cs | 21 ++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery14.cs 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/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs index 56046f6..9414ccf 100644 --- a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs +++ b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs @@ -46,6 +46,8 @@ public sealed class NPlusOneQueryAnalyzer : DiagnosticAnalyzer "TestMethodAttribute", "TestClassAttribute" }.ToFrozenSet(); + private const string AnalyzeTestMethodsOption = "dotnet_diagnostic.CI0011.analyze_test_methods"; + public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); @@ -61,6 +63,15 @@ public override void Initialize(AnalysisContext context) private static void AnalyzeLinqInvocation(SyntaxNodeAnalysisContext context) { if (context.Node is not InvocationExpressionSyntax invocation) return; + + // 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) + && !IsAnalyzeTestMethodsEnabled(context)) + { + 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 @@ -111,9 +122,10 @@ private static void AnalyzeLoopNode(SyntaxNodeAnalysisContext context) { var loopNode = context.Node; - // Skip analysis if we are inside a test method to avoid false positives in tests + // 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)) + && IsTestMethod(enclosingMethodSymbol) + && !IsAnalyzeTestMethodsEnabled(context)) { return; } @@ -244,6 +256,13 @@ private static IReadOnlyCollection GetLoopVariableSymbols(SyntaxNode lo return symbols; } + private static bool IsAnalyzeTestMethodsEnabled(SyntaxNodeAnalysisContext context) + { + var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree); + return options.TryGetValue(AnalyzeTestMethodsOption, out var value) && + bool.TryParse(value, out var result) && result; + } + private static bool IsTestMethod(IMethodSymbol methodSymbol) { if (HasTestAttribute(methodSymbol.GetAttributes())) return true; diff --git a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs index 7916090..5696513 100644 --- a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs +++ b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs @@ -112,4 +112,46 @@ public Task LinqSelect_WithBlockBody_ShouldWarn() 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(); + } } 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(); + } +} From 99d41b5c3fb2409f906e4b82a9cad4d46c2144fc Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Wed, 5 Aug 2026 16:22:42 +0700 Subject: [PATCH 05/11] support configuration --- .../CI0011/NPlusOneQueryAnalyzer.cs | 47 +++++++++++++++---- .../NPlusOneQueryTests/NPlusOneQueryTests.cs | 46 ++++++++++++++++++ .../NPlusOneQuery/NPlusOneQuery15.cs | 19 ++++++++ .../NPlusOneQuery/NPlusOneQuery15_NoMarkup.cs | 19 ++++++++ .../NPlusOneQuery/NPlusOneQuery16.cs | 19 ++++++++ .../NPlusOneQuery/NPlusOneQuery16_NoMarkup.cs | 19 ++++++++ 6 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery15.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery15_NoMarkup.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery16.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery16_NoMarkup.cs diff --git a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs index 9414ccf..203678d 100644 --- a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs +++ b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Collections.Immutable; @@ -46,7 +47,11 @@ public sealed class NPlusOneQueryAnalyzer : DiagnosticAnalyzer "TestMethodAttribute", "TestClassAttribute" }.ToFrozenSet(); + private static readonly char[] Separator = { ',' }; + private const string AnalyzeTestMethodsOption = "dotnet_diagnostic.CI0011.analyze_test_methods"; + private const string DataAccessTypeSuffixesOption = "dotnet_diagnostic.CI0011.data_access_type_suffixes"; + private const string DataAccessMethodPrefixesOption = "dotnet_diagnostic.CI0011.data_access_method_prefixes"; public override void Initialize(AnalysisContext context) { @@ -160,7 +165,7 @@ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, Invocat } // If a data access method is called using a loop variable, report a diagnostic - if (IsDataAccessMethod(methodSymbol)) + if (IsDataAccessMethod(methodSymbol, context)) { var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation(), methodSymbol.Name); context.ReportDiagnostic(diagnostic); @@ -186,35 +191,37 @@ private static bool UsesLoopVariable(SyntaxNodeAnalysisContext context, Invocati return false; } - private static bool IsDataAccessMethod(IMethodSymbol methodSymbol) + private static bool IsDataAccessMethod(IMethodSymbol methodSymbol, SyntaxNodeAnalysisContext context) { - if (!IsDataAccessType(methodSymbol.ContainingType)) + if (!IsDataAccessType(methodSymbol.ContainingType, context)) { return false; } var methodName = methodSymbol.Name; - return DataAccessMethodPrefixes.Any(methodName.StartsWith); + var prefixes = GetDataAccessMethodPrefixes(context); + return prefixes.Any(methodName.StartsWith); } - private static bool IsDataAccessType(INamedTypeSymbol? type) + private static bool IsDataAccessType(INamedTypeSymbol? type, SyntaxNodeAnalysisContext context) { if (type == null) { return false; } - if (IsDataAccessTypeName(type.Name)) + var suffixes = GetDataAccessTypeSuffixes(context); + if (IsDataAccessTypeName(type.Name, suffixes)) { return true; } - return type.AllInterfaces.Any(i => IsDataAccessTypeName(i.Name)); + return type.AllInterfaces.Any(i => IsDataAccessTypeName(i.Name, suffixes)); } - private static bool IsDataAccessTypeName(string typeName) + private static bool IsDataAccessTypeName(string typeName, IEnumerable suffixes) { - return !string.IsNullOrEmpty(typeName) && DataAccessTypeSuffixes.Any(typeName.Contains); + return !string.IsNullOrEmpty(typeName) && suffixes.Any(typeName.Contains); } private static IReadOnlyCollection GetLoopVariableSymbols(SyntaxNode loopNode, SemanticModel semanticModel) @@ -263,6 +270,28 @@ private static bool IsAnalyzeTestMethodsEnabled(SyntaxNodeAnalysisContext contex bool.TryParse(value, out var result) && result; } + private static IReadOnlyCollection GetDataAccessTypeSuffixes(SyntaxNodeAnalysisContext context) + { + var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree); + if (options.TryGetValue(DataAccessTypeSuffixesOption, out var value) && !string.IsNullOrWhiteSpace(value)) + { + return value.Split(Separator, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + } + + return DataAccessTypeSuffixes; + } + + private static IReadOnlyCollection GetDataAccessMethodPrefixes(SyntaxNodeAnalysisContext context) + { + var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree); + if (options.TryGetValue(DataAccessMethodPrefixesOption, out var value) && !string.IsNullOrWhiteSpace(value)) + { + return value.Split(Separator, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + } + + return DataAccessMethodPrefixes; + } + private static bool IsTestMethod(IMethodSymbol methodSymbol) { if (HasTestAttribute(methodSymbol.GetAttributes())) return true; diff --git a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs index 5696513..6364a2c 100644 --- a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs +++ b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs @@ -154,4 +154,50 @@ public Task TestMethod_WhenEnabledViaEditorConfig_ShouldWarn() return test.RunAsync(); } + + [Test] + public Task CustomTypeSuffixes_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_suffixes = 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) + ); + } } 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(); + } +} From 88ff6ddb8c1cd4874ee9f7d588ee5e3588aeca69 Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Wed, 5 Aug 2026 16:59:20 +0700 Subject: [PATCH 06/11] cache configuration --- .../Diagnostics/AnalyzerConfigHelper.cs | 26 +++++ .../ArrayContainsToHashSetDiagnostic.cs | 34 +++---- .../CI0011/NPlusOneQueryAnalyzer.cs | 99 ++++++++++--------- 3 files changed, 95 insertions(+), 64 deletions(-) create mode 100644 Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs diff --git a/Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs b/Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs new file mode 100644 index 0000000..1fad39e --- /dev/null +++ b/Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs @@ -0,0 +1,26 @@ +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(); + + public static T GetConfig(AnalyzerOptions options, SyntaxTree syntaxTree, System.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; + } +} 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 index 203678d..60f1018 100644 --- a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs +++ b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs @@ -42,12 +42,11 @@ public sealed class NPlusOneQueryAnalyzer : DiagnosticAnalyzer }.ToFrozenSet(); private static readonly FrozenSet TestAttributeNames = - new[] { - "FactAttribute", "TheoryAttribute", "TestAttribute", "TestCaseAttribute", "TestFixtureAttribute", - "TestMethodAttribute", "TestClassAttribute" - }.ToFrozenSet(); - - private static readonly char[] Separator = { ',' }; + new[] + { + "FactAttribute", "TheoryAttribute", "TestAttribute", "TestCaseAttribute", "TestFixtureAttribute", + "TestMethodAttribute", "TestClassAttribute" + }.ToFrozenSet(); private const string AnalyzeTestMethodsOption = "dotnet_diagnostic.CI0011.analyze_test_methods"; private const string DataAccessTypeSuffixesOption = "dotnet_diagnostic.CI0011.data_access_type_suffixes"; @@ -69,10 +68,11 @@ 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) - && !IsAnalyzeTestMethodsEnabled(context)) + && !config.AnalyzeTestMethodsEnabled) { return; } @@ -114,7 +114,7 @@ private static IReadOnlyCollection GetLambdaParameters(LambdaExpression { SimpleLambdaExpressionSyntax simple => new[] { simple.Parameter }, ParenthesizedLambdaExpressionSyntax parenthesized => parenthesized.ParameterList.Parameters.ToArray(), - _ => System.Array.Empty() + _ => Array.Empty() }; return parameters @@ -127,10 +127,11 @@ 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) - && !IsAnalyzeTestMethodsEnabled(context)) + && !config.AnalyzeTestMethodsEnabled) { return; } @@ -164,8 +165,9 @@ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, Invocat return; } + var config = GetConfig(context); // If a data access method is called using a loop variable, report a diagnostic - if (IsDataAccessMethod(methodSymbol, context)) + if (IsDataAccessMethod(methodSymbol, config)) { var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation(), methodSymbol.Name); context.ReportDiagnostic(diagnostic); @@ -191,32 +193,30 @@ private static bool UsesLoopVariable(SyntaxNodeAnalysisContext context, Invocati return false; } - private static bool IsDataAccessMethod(IMethodSymbol methodSymbol, SyntaxNodeAnalysisContext context) + private static bool IsDataAccessMethod(IMethodSymbol methodSymbol, NPlusOneQueryConfig config) { - if (!IsDataAccessType(methodSymbol.ContainingType, context)) + if (!IsDataAccessType(methodSymbol.ContainingType, config)) { return false; } var methodName = methodSymbol.Name; - var prefixes = GetDataAccessMethodPrefixes(context); - return prefixes.Any(methodName.StartsWith); + return config.DataAccessMethodPrefixes.Any(methodName.StartsWith); } - private static bool IsDataAccessType(INamedTypeSymbol? type, SyntaxNodeAnalysisContext context) + private static bool IsDataAccessType(INamedTypeSymbol? type, NPlusOneQueryConfig config) { if (type == null) { return false; } - var suffixes = GetDataAccessTypeSuffixes(context); - if (IsDataAccessTypeName(type.Name, suffixes)) + if (IsDataAccessTypeName(type.Name, config.DataAccessTypeSuffixes)) { return true; } - return type.AllInterfaces.Any(i => IsDataAccessTypeName(i.Name, suffixes)); + return type.AllInterfaces.Any(i => IsDataAccessTypeName(i.Name, config.DataAccessTypeSuffixes)); } private static bool IsDataAccessTypeName(string typeName, IEnumerable suffixes) @@ -263,49 +263,56 @@ private static IReadOnlyCollection GetLoopVariableSymbols(SyntaxNode lo return symbols; } - private static bool IsAnalyzeTestMethodsEnabled(SyntaxNodeAnalysisContext context) + private static bool IsTestMethod(IMethodSymbol methodSymbol) { - var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree); - return options.TryGetValue(AnalyzeTestMethodsOption, out var value) && - bool.TryParse(value, out var result) && result; + if (HasTestAttribute(methodSymbol.GetAttributes())) return true; + + return methodSymbol.ContainingType != null && + HasTestAttribute(methodSymbol.ContainingType.GetAttributes()); } - private static IReadOnlyCollection GetDataAccessTypeSuffixes(SyntaxNodeAnalysisContext context) + private static bool HasTestAttribute(ImmutableArray attributes) { - var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree); - if (options.TryGetValue(DataAccessTypeSuffixesOption, out var value) && !string.IsNullOrWhiteSpace(value)) + return attributes.Any(attribute => { - return value.Split(Separator, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); - } - - return DataAccessTypeSuffixes; + var name = attribute.AttributeClass?.Name; + return name != null && TestAttributeNames.Contains(name); + }); } - private static IReadOnlyCollection GetDataAccessMethodPrefixes(SyntaxNodeAnalysisContext context) + private sealed class NPlusOneQueryConfig { - var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree); - if (options.TryGetValue(DataAccessMethodPrefixesOption, out var value) && !string.IsNullOrWhiteSpace(value)) + private static readonly char[] Separators = { ',', ';', '|' }; + public bool AnalyzeTestMethodsEnabled { get; } + public IReadOnlyCollection DataAccessTypeSuffixes { get; } + public IReadOnlyCollection DataAccessMethodPrefixes { get; } + + private NPlusOneQueryConfig(bool analyzeTestMethodsEnabled, IReadOnlyCollection dataAccessTypeSuffixes, IReadOnlyCollection dataAccessMethodPrefixes) { - return value.Split(Separator, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + AnalyzeTestMethodsEnabled = analyzeTestMethodsEnabled; + DataAccessTypeSuffixes = dataAccessTypeSuffixes; + DataAccessMethodPrefixes = dataAccessMethodPrefixes; } - return DataAccessMethodPrefixes; - } + public static NPlusOneQueryConfig FromOptions(AnalyzerConfigOptions options) + { + var analyzeTestMethodsEnabled = options.TryGetValue(AnalyzeTestMethodsOption, out var testMethodsValue) && + bool.TryParse(testMethodsValue, out var testMethodsResult) && testMethodsResult; - private static bool IsTestMethod(IMethodSymbol methodSymbol) - { - if (HasTestAttribute(methodSymbol.GetAttributes())) return true; + var typeSuffixes = options.TryGetValue(DataAccessTypeSuffixesOption, out var typeSuffixesValue) && !string.IsNullOrWhiteSpace(typeSuffixesValue) + ? typeSuffixesValue.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray() + : NPlusOneQueryAnalyzer.DataAccessTypeSuffixes; - return methodSymbol.ContainingType != null && - HasTestAttribute(methodSymbol.ContainingType.GetAttributes()); + var methodPrefixes = options.TryGetValue(DataAccessMethodPrefixesOption, out var methodPrefixesValue) && !string.IsNullOrWhiteSpace(methodPrefixesValue) + ? methodPrefixesValue.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray() + : NPlusOneQueryAnalyzer.DataAccessMethodPrefixes; + + return new NPlusOneQueryConfig(analyzeTestMethodsEnabled, typeSuffixes, methodPrefixes); + } } - private static bool HasTestAttribute(ImmutableArray attributes) + private static NPlusOneQueryConfig GetConfig(SyntaxNodeAnalysisContext context) { - return attributes.Any(attribute => - { - var name = attribute.AttributeClass?.Name; - return name != null && TestAttributeNames.Contains(name); - }); + return AnalyzerConfigHelper.GetConfig(context.Options, context.Node.SyntaxTree, NPlusOneQueryConfig.FromOptions); } } From 2b5536caf1993bc11a7f6453e8dd078aa2039cbc Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Wed, 5 Aug 2026 17:10:11 +0700 Subject: [PATCH 07/11] - version 0.3.0 - documentation --- CHANGELOG.md | 5 ++ .../AnalyzerReleases.Shipped.md | 8 +++ .../Collections.Analyzer.csproj | 2 +- Documentation/CI0011.md | 72 +++++++++++++++++++ Documentation/Diagnostics.md | 3 +- README.md | 4 +- 6 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 Documentation/CI0011.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fd9f3d2..31eb4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 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 suffixes, method prefixes, and test method analysis). +- Improved analyzer performance by implementing configuration caching. + ## 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/Documentation/CI0011.md b/Documentation/CI0011.md new file mode 100644 index 0000000..39ee6eb --- /dev/null +++ b/Documentation/CI0011.md @@ -0,0 +1,72 @@ +# 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" 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 with the following suffixes: `Repository`, `Reader`, `Writer`, `Handler`. You can override this list: +```ini +dotnet_diagnostic.CI0011.data_access_type_suffixes = 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 can use comma (`,`), semicolon (`;`), or pipe (`|`) as separators. +```ini +dotnet_diagnostic.CI0011.data_access_method_prefixes = Get; Fetch | Query +``` + +## 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..9011f20 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,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 +44,7 @@ can add file `Directory.build.props` to the solution directory with content: ``` - + ``` From 180e8a84978dab0325f565bd1ce83a59dcc7d1ac Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Wed, 5 Aug 2026 18:09:09 +0700 Subject: [PATCH 08/11] skip static and bulk methods --- CHANGELOG.md | 4 +- .../CI0011/NPlusOneQueryAnalyzer.cs | 49 +++++++++++++++++-- Documentation/CI0011.md | 15 ++++++ README.md | 4 +- .../NPlusOneQueryTests/NPlusOneQueryTests.cs | 41 ++++++++++++++++ .../NPlusOneQuery/NPlusOneQuery17.cs | 48 ++++++++++++++++++ .../NPlusOneQuery/NPlusOneQuery18.cs | 45 +++++++++++++++++ .../NPlusOneQuery/NPlusOneQuery20.cs | 41 ++++++++++++++++ .../NPlusOneQuery/NPlusOneQuery21.cs | 29 +++++++++++ 9 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery17.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery18.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery20.cs create mode 100644 Tests/Resources/NPlusOneQuery/NPlusOneQuery21.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 31eb4f8..3edbb36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ ## 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 suffixes, method prefixes, and test method analysis). +- Added support for `.editorconfig` configuration for CI0011 (data access type suffixes, 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/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs index 60f1018..3eb59a5 100644 --- a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs +++ b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs @@ -27,6 +27,7 @@ public sealed class NPlusOneQueryAnalyzer : DiagnosticAnalyzer private static readonly string[] DataAccessTypeSuffixes = { "Repository", "Reader", "Writer", "Handler" }; private static readonly string[] DataAccessMethodPrefixes = { "Read", "Find", "Get", "TryRead", "TryGet", "TryFind" }; + private static readonly string[] BulkMethodSubstrings = { "Batch", "Bulk", "Range" }; private static readonly FrozenSet LinqMethodNames = new[] { @@ -51,6 +52,7 @@ public sealed class NPlusOneQueryAnalyzer : DiagnosticAnalyzer private const string AnalyzeTestMethodsOption = "dotnet_diagnostic.CI0011.analyze_test_methods"; private const string DataAccessTypeSuffixesOption = "dotnet_diagnostic.CI0011.data_access_type_suffixes"; private const string DataAccessMethodPrefixesOption = "dotnet_diagnostic.CI0011.data_access_method_prefixes"; + private const string BulkMethodSubstringsOption = "dotnet_diagnostic.CI0011.bulk_method_substrings"; public override void Initialize(AnalysisContext context) { @@ -195,15 +197,46 @@ private static bool UsesLoopVariable(SyntaxNodeAnalysisContext context, Invocati private static bool IsDataAccessMethod(IMethodSymbol methodSymbol, NPlusOneQueryConfig config) { - if (!IsDataAccessType(methodSymbol.ContainingType, 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) @@ -286,12 +319,18 @@ private sealed class NPlusOneQueryConfig public bool AnalyzeTestMethodsEnabled { get; } public IReadOnlyCollection DataAccessTypeSuffixes { get; } public IReadOnlyCollection DataAccessMethodPrefixes { get; } + public IReadOnlyCollection BulkMethodSubstrings { get; } - private NPlusOneQueryConfig(bool analyzeTestMethodsEnabled, IReadOnlyCollection dataAccessTypeSuffixes, IReadOnlyCollection dataAccessMethodPrefixes) + private NPlusOneQueryConfig( + bool analyzeTestMethodsEnabled, + IReadOnlyCollection dataAccessTypeSuffixes, + IReadOnlyCollection dataAccessMethodPrefixes, + IReadOnlyCollection bulkMethodSubstrings) { AnalyzeTestMethodsEnabled = analyzeTestMethodsEnabled; DataAccessTypeSuffixes = dataAccessTypeSuffixes; DataAccessMethodPrefixes = dataAccessMethodPrefixes; + BulkMethodSubstrings = bulkMethodSubstrings; } public static NPlusOneQueryConfig FromOptions(AnalyzerConfigOptions options) @@ -307,7 +346,11 @@ public static NPlusOneQueryConfig FromOptions(AnalyzerConfigOptions options) ? methodPrefixesValue.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray() : NPlusOneQueryAnalyzer.DataAccessMethodPrefixes; - return new NPlusOneQueryConfig(analyzeTestMethodsEnabled, typeSuffixes, methodPrefixes); + var bulkSubstrings = options.TryGetValue(BulkMethodSubstringsOption, out var bulkSubstringsValue) && !string.IsNullOrWhiteSpace(bulkSubstringsValue) + ? bulkSubstringsValue.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray() + : NPlusOneQueryAnalyzer.BulkMethodSubstrings; + + return new NPlusOneQueryConfig(analyzeTestMethodsEnabled, typeSuffixes, methodPrefixes, bulkSubstrings); } } diff --git a/Documentation/CI0011.md b/Documentation/CI0011.md index 39ee6eb..d28e5c4 100644 --- a/Documentation/CI0011.md +++ b/Documentation/CI0011.md @@ -34,6 +34,21 @@ For list-based options, you can use comma (`,`), semicolon (`;`), or pipe (`|`) 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: diff --git a/README.md b/README.md index 9011f20..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) diff --git a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs index 6364a2c..967c3eb 100644 --- a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs +++ b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs @@ -113,6 +113,30 @@ public Task LinqSelect_WithBlockBody_ShouldWarn() 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() { @@ -200,4 +224,21 @@ public Task CustomConfiguration_WithoutSetting_ShouldNotWarn() 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/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/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)|}; + } + } + } +} From 3aced65fab70ccbc2f57833222e1fa9842963364 Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Thu, 6 Aug 2026 14:05:33 +0700 Subject: [PATCH 09/11] refactorings --- CHANGELOG.md | 2 +- .../Diagnostics/AnalyzerConfigHelper.cs | 12 ++++- .../CI0011/NPlusOneQueryAnalyzer.cs | 46 ++++++++----------- Documentation/CI0011.md | 8 ++-- .../NPlusOneQueryTests/NPlusOneQueryTests.cs | 4 +- 5 files changed, 37 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3edbb36..aa7745c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## 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 suffixes, method prefixes, bulk method substrings, and test method analysis). +- 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. diff --git a/Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs b/Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs index 1fad39e..9d24c00 100644 --- a/Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs +++ b/Collections.Analyzer/Diagnostics/AnalyzerConfigHelper.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq; using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; @@ -7,8 +9,9 @@ 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, System.Func factory) + public static T GetConfig(AnalyzerOptions options, SyntaxTree syntaxTree, Func factory) { if (ConfigCache.TryGetValue(syntaxTree, out var cached) && cached is T typedConfig) { @@ -23,4 +26,11 @@ public static T GetConfig(AnalyzerOptions options, SyntaxTree syntaxTree, Sys 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/CI0011/NPlusOneQueryAnalyzer.cs b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs index 3eb59a5..c0948fe 100644 --- a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs +++ b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs @@ -24,11 +24,7 @@ public sealed class NPlusOneQueryAnalyzer : DiagnosticAnalyzer true, Resources.CI0011_Description ); - - private static readonly string[] DataAccessTypeSuffixes = { "Repository", "Reader", "Writer", "Handler" }; - private static readonly string[] DataAccessMethodPrefixes = { "Read", "Find", "Get", "TryRead", "TryGet", "TryFind" }; - private static readonly string[] BulkMethodSubstrings = { "Batch", "Bulk", "Range" }; - + private static readonly FrozenSet LinqMethodNames = new[] { nameof(Enumerable.Select), @@ -50,7 +46,7 @@ public sealed class NPlusOneQueryAnalyzer : DiagnosticAnalyzer }.ToFrozenSet(); private const string AnalyzeTestMethodsOption = "dotnet_diagnostic.CI0011.analyze_test_methods"; - private const string DataAccessTypeSuffixesOption = "dotnet_diagnostic.CI0011.data_access_type_suffixes"; + 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"; @@ -180,7 +176,7 @@ private static bool UsesLoopVariable(SyntaxNodeAnalysisContext context, Invocati { foreach (var argument in invocation.ArgumentList.Arguments) { - var dataFlowAnalysis = context.SemanticModel.AnalyzeDataFlow(argument.Expression); + var dataFlowAnalysis = context.SemanticModel.AnalyzeDataFlow(argument.Expression)!; if (!dataFlowAnalysis.Succeeded) { continue; @@ -244,17 +240,17 @@ private static bool IsDataAccessType(INamedTypeSymbol? type, NPlusOneQueryConfig return false; } - if (IsDataAccessTypeName(type.Name, config.DataAccessTypeSuffixes)) + if (IsDataAccessTypeName(type.Name, config.DataAccessTypeSubstrings)) { return true; } - return type.AllInterfaces.Any(i => IsDataAccessTypeName(i.Name, config.DataAccessTypeSuffixes)); + return type.AllInterfaces.Any(i => IsDataAccessTypeName(i.Name, config.DataAccessTypeSubstrings)); } - private static bool IsDataAccessTypeName(string typeName, IEnumerable suffixes) + private static bool IsDataAccessTypeName(string typeName, IEnumerable substrings) { - return !string.IsNullOrEmpty(typeName) && suffixes.Any(typeName.Contains); + return !string.IsNullOrEmpty(typeName) && substrings.Any(typeName.Contains); } private static IReadOnlyCollection GetLoopVariableSymbols(SyntaxNode loopNode, SemanticModel semanticModel) @@ -315,20 +311,24 @@ private static bool HasTestAttribute(ImmutableArray attributes) private sealed class NPlusOneQueryConfig { - private static readonly char[] Separators = { ',', ';', '|' }; public bool AnalyzeTestMethodsEnabled { get; } - public IReadOnlyCollection DataAccessTypeSuffixes { 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 NPlusOneQueryConfig( bool analyzeTestMethodsEnabled, - IReadOnlyCollection dataAccessTypeSuffixes, + IReadOnlyCollection dataAccessTypeSubstrings, IReadOnlyCollection dataAccessMethodPrefixes, IReadOnlyCollection bulkMethodSubstrings) { AnalyzeTestMethodsEnabled = analyzeTestMethodsEnabled; - DataAccessTypeSuffixes = dataAccessTypeSuffixes; + DataAccessTypeSubstrings = dataAccessTypeSubstrings; DataAccessMethodPrefixes = dataAccessMethodPrefixes; BulkMethodSubstrings = bulkMethodSubstrings; } @@ -338,19 +338,11 @@ public static NPlusOneQueryConfig FromOptions(AnalyzerConfigOptions options) var analyzeTestMethodsEnabled = options.TryGetValue(AnalyzeTestMethodsOption, out var testMethodsValue) && bool.TryParse(testMethodsValue, out var testMethodsResult) && testMethodsResult; - var typeSuffixes = options.TryGetValue(DataAccessTypeSuffixesOption, out var typeSuffixesValue) && !string.IsNullOrWhiteSpace(typeSuffixesValue) - ? typeSuffixesValue.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray() - : NPlusOneQueryAnalyzer.DataAccessTypeSuffixes; - - var methodPrefixes = options.TryGetValue(DataAccessMethodPrefixesOption, out var methodPrefixesValue) && !string.IsNullOrWhiteSpace(methodPrefixesValue) - ? methodPrefixesValue.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray() - : NPlusOneQueryAnalyzer.DataAccessMethodPrefixes; - - var bulkSubstrings = options.TryGetValue(BulkMethodSubstringsOption, out var bulkSubstringsValue) && !string.IsNullOrWhiteSpace(bulkSubstringsValue) - ? bulkSubstringsValue.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray() - : NPlusOneQueryAnalyzer.BulkMethodSubstrings; + 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, typeSuffixes, methodPrefixes, bulkSubstrings); + return new NPlusOneQueryConfig(analyzeTestMethodsEnabled, typeSubstrings, methodPrefixes, bulkSubstrings); } } diff --git a/Documentation/CI0011.md b/Documentation/CI0011.md index d28e5c4..70e6971 100644 --- a/Documentation/CI0011.md +++ b/Documentation/CI0011.md @@ -8,9 +8,9 @@ The analyzer can be customized via `.editorconfig` to match your project's namin ### Data Access Types -By default, the analyzer looks for methods in classes with the following suffixes: `Repository`, `Reader`, `Writer`, `Handler`. You can override this list: +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_suffixes = Repository, Service, Store +dotnet_diagnostic.CI0011.data_access_type_substrings = Repository, Service, Store ``` ### Data Access Methods @@ -29,9 +29,9 @@ dotnet_diagnostic.CI0011.analyze_test_methods = true ### Multiple Values -For list-based options, you can use comma (`,`), semicolon (`;`), or pipe (`|`) as separators. +For list-based options, you must use a comma (`,`) as a separator. ```ini -dotnet_diagnostic.CI0011.data_access_method_prefixes = Get; Fetch | Query +dotnet_diagnostic.CI0011.data_access_method_prefixes = Get, Fetch, Query ``` ### Static Methods diff --git a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs index 967c3eb..b035e39 100644 --- a/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs +++ b/Tests/NPlusOneQueryTests/NPlusOneQueryTests.cs @@ -180,7 +180,7 @@ public Task TestMethod_WhenEnabledViaEditorConfig_ShouldWarn() } [Test] - public Task CustomTypeSuffixes_ShouldWarn() + public Task CustomTypeSubstrings_ShouldWarn() { var code = ResourceReader.ReadFromFile("NPlusOneQuery15.cs"); @@ -190,7 +190,7 @@ public Task CustomTypeSuffixes_ShouldWarn() }; test.TestState.AnalyzerConfigFiles.Add( - ("/.editorconfig", $"is_global = true{System.Environment.NewLine}dotnet_diagnostic.CI0011.data_access_type_suffixes = Service") + ("/.editorconfig", $"is_global = true{System.Environment.NewLine}dotnet_diagnostic.CI0011.data_access_type_substrings = Service") ); return test.RunAsync(); From c02681f4b020b9bf3cb1fae0371ec62ace8768a8 Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Thu, 6 Aug 2026 20:40:12 +0700 Subject: [PATCH 10/11] fixes --- .../CI0011/NPlusOneQueryAnalyzer.cs | 39 +++++++++++-------- Documentation/CI0011.md | 2 +- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs index c0948fe..9a89072 100644 --- a/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs +++ b/Collections.Analyzer/Diagnostics/CI0011/NPlusOneQueryAnalyzer.cs @@ -44,12 +44,7 @@ public sealed class NPlusOneQueryAnalyzer : DiagnosticAnalyzer "FactAttribute", "TheoryAttribute", "TestAttribute", "TestCaseAttribute", "TestFixtureAttribute", "TestMethodAttribute", "TestClassAttribute" }.ToFrozenSet(); - - 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"; - + public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); @@ -67,7 +62,8 @@ 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 + + // 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) @@ -106,19 +102,19 @@ private static void AnalyzeLinqInvocation(SyntaxNodeAnalysisContext context) } } - private static IReadOnlyCollection GetLambdaParameters(LambdaExpressionSyntax lambda, SemanticModel semanticModel) + private static ISet GetLambdaParameters(LambdaExpressionSyntax lambda, SemanticModel semanticModel) { - var parameters = lambda switch + IEnumerable parameters = lambda switch { SimpleLambdaExpressionSyntax simple => new[] { simple.Parameter }, - ParenthesizedLambdaExpressionSyntax parenthesized => parenthesized.ParameterList.Parameters.ToArray(), + ParenthesizedLambdaExpressionSyntax parenthesized => parenthesized.ParameterList.Parameters, _ => Array.Empty() }; return parameters .Select(p => semanticModel.GetDeclaredSymbol(p)) .OfType() - .ToList(); + .ToImmutableHashSet(SymbolEqualityComparer.Default); } private static void AnalyzeLoopNode(SyntaxNodeAnalysisContext context) @@ -126,7 +122,7 @@ 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 + // 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) @@ -149,7 +145,10 @@ private static void AnalyzeLoopNode(SyntaxNodeAnalysisContext context) } } - private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation, IReadOnlyCollection loopVariables) + private static void AnalyzeInvocation( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + ISet loopVariables) { var methodSymbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol; if (methodSymbol == null) @@ -172,7 +171,10 @@ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, Invocat } } - private static bool UsesLoopVariable(SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation, IReadOnlyCollection loopVariables) + private static bool UsesLoopVariable( + SyntaxNodeAnalysisContext context, + InvocationExpressionSyntax invocation, + ISet loopVariables) { foreach (var argument in invocation.ArgumentList.Arguments) { @@ -182,7 +184,7 @@ private static bool UsesLoopVariable(SyntaxNodeAnalysisContext context, Invocati continue; } - if (dataFlowAnalysis.ReadInside.Any(symbol => loopVariables.Contains(symbol, SymbolEqualityComparer.Default))) + if (dataFlowAnalysis.ReadInside.Any(loopVariables.Contains)) { return true; } @@ -253,7 +255,7 @@ private static bool IsDataAccessTypeName(string typeName, IEnumerable su return !string.IsNullOrEmpty(typeName) && substrings.Any(typeName.Contains); } - private static IReadOnlyCollection GetLoopVariableSymbols(SyntaxNode loopNode, SemanticModel semanticModel) + private static ISet GetLoopVariableSymbols(SyntaxNode loopNode, SemanticModel semanticModel) { var symbols = new HashSet(SymbolEqualityComparer.Default); @@ -320,6 +322,11 @@ private sealed class NPlusOneQueryConfig 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, diff --git a/Documentation/CI0011.md b/Documentation/CI0011.md index 70e6971..fe883e9 100644 --- a/Documentation/CI0011.md +++ b/Documentation/CI0011.md @@ -1,6 +1,6 @@ # 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" because it results in N additional database queries where a single bulk query could have been used. +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 From df8957c3079e1434f21c05cd72ce350212531e90 Mon Sep 17 00:00:00 2001 From: Rogatnev Sergey Date: Fri, 7 Aug 2026 06:10:09 +0700 Subject: [PATCH 11/11] publish.yml --- .github/workflows/publish.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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