From 06af5c5c8633e7aaf4a179f63a43c6b2c4784d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clavek=2C=20Ond=C5=99ej?= Date: Fri, 28 Aug 2026 15:37:07 +0200 Subject: [PATCH 1/3] =?UTF-8?q?HFW1004:=20Analyzer=20pou=C5=BEit=C3=AD=20F?= =?UTF-8?q?ilteringCollection=20v=20dotazu=20do=20datab=C3=A1ze?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FilteringCollection je in-memory wrapper nad namapovanou kolekcí, EF Core ji nepřeloží do SQL. Dotaz buď spadne za běhu (Where, OrderBy, SelectMany, Include), nebo - a to je horší - ve finální projekci tiše nevrátí žádná data: SQL na tabulku vůbec nesáhne a klientské vyhodnocení běží nad nenaplněnou kolekcí. Analyzer hlásí přístup ke členu typu FilteringCollection uvnitř expression tree. Tvar "entity => entity.Collection" (tj. celé tělo lambdy) hlásí jen tehdy, jde-li o metodu nad IQueryable (Include) - stejný tvar totiž používá i IDataLoader, FluentValidation apod. Načítání přes IDataLoader/IFluentDataLoader je navíc vyloučeno explicitně: data loader FilteringCollection podporuje substitucí kolekce XIncludingDeleted. Hláška navrhne konkrétní jméno XIncludingDeleted, pokud ho entita deklaruje. --- DiagnosticsIdentifiers.cs | 4 + ...CollectionInExpressionTreeAnalyzerTests.cs | 331 ++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 6 + .../Diagnostics.cs | 19 + .../FilteringCollectionConstants.cs | 20 ++ ...eringCollectionInExpressionTreeAnalyzer.cs | 239 +++++++++++++ 6 files changed, 619 insertions(+) create mode 100644 Havit.Data.EntityFrameworkCore.Patterns.Analyzers.Tests/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzerTests.cs create mode 100644 Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionConstants.cs create mode 100644 Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzer.cs diff --git a/DiagnosticsIdentifiers.cs b/DiagnosticsIdentifiers.cs index fcd483535..5191e0b12 100644 --- a/DiagnosticsIdentifiers.cs +++ b/DiagnosticsIdentifiers.cs @@ -20,6 +20,10 @@ internal static class DiagnosticIdentifiers /// public const string UnitOfWorkAddRangeNestedCollectionId = "HFW1003"; // Category: Usage + /// + /// FilteringCollection used within an expression tree evaluated by a database query. + /// + public const string FilteringCollectionInExpressionTreeId = "HFW1004"; // Category: Usage } } diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.Tests/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzerTests.cs b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.Tests/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzerTests.cs new file mode 100644 index 000000000..167d43fef --- /dev/null +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.Tests/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzerTests.cs @@ -0,0 +1,331 @@ +using Havit.Data.EntityFrameworkCore.Patterns.Analyzers.FilteringCollections; +using Havit.Model.Collections.Generic; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; + +namespace Havit.Data.EntityFrameworkCore.Patterns.Analyzers.Tests.FilteringCollections; + +[TestClass] +public class FilteringCollectionInExpressionTreeAnalyzerTests +{ + /// + /// Model, na kterém jsou postaveny všechny testy: Master.Children je FilteringCollection nad namapovanou + /// kolekcí Master.ChildrenIncludingDeleted, Master.Others je FilteringCollection bez namapovaného protějšku. + /// Součástí je i Include extension metoda se stejnou signaturou, jakou má EF Core (aby testy nemusely + /// referencovat EF Core) a metoda přijímající Expression mimo IQueryable (tvar, jaký má např. FluentValidation RuleFor). + /// + private const string ModelDeclarations = @" +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Havit.Data.Patterns.DataLoaders; +using Havit.Model.Collections.Generic; + +namespace TestNamespace +{ + public class Child + { + public int Id { get; set; } + public DateTime? Deleted { get; set; } + public Master Master { get; set; } + } + + public class Master + { + public int Id { get; set; } + public List ChildrenIncludingDeleted { get; } = new List(); + public FilteringCollection Children { get; } + public FilteringCollection Others { get; } + + public Master() + { + Children = new FilteringCollection(ChildrenIncludingDeleted, child => child.Deleted == null); + Others = new FilteringCollection(ChildrenIncludingDeleted, child => child.Deleted != null); + } + } + + public static class QueryableExtensions + { + public static IQueryable Include(this IQueryable source, Expression> navigationPropertyPath) => source; + } + + public static class Validation + { + public static void RuleFor(Expression> propertyPath) + { + } + } +} +"; + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_WhereWithAnyOnFilteringCollection_ReportsDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IQueryable masters) + { + masters.Where(master => {|#0:master.Children|}.Any()); + } + } +}"; + + await VerifyAnalyzerAsync(source, ExpectedDiagnostic("Children", "'ChildrenIncludingDeleted' with an explicit filter")); + } + + /// + /// Nejzrádnější případ: ve finální projekci EF Core nehlásí chybu, jen kolekci vůbec nenačte a vrátí prázdno. + /// + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_CountInProjection_ReportsDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IQueryable masters) + { + masters.Select(master => new { master.Id, Count = {|#0:master.Children|}.Count }); + } + } +}"; + + await VerifyAnalyzerAsync(source, ExpectedDiagnostic("Children", "'ChildrenIncludingDeleted' with an explicit filter")); + } + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_NestedSubqueryInProjection_ReportsDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IQueryable masters) + { + masters.Select(master => new { Ids = {|#0:master.Children|}.Select(child => child.Id).ToList() }); + } + } +}"; + + await VerifyAnalyzerAsync(source, ExpectedDiagnostic("Children", "'ChildrenIncludingDeleted' with an explicit filter")); + } + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_IncludeOfFilteringCollection_ReportsDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IQueryable masters) + { + masters.Include(master => {|#0:master.Children|}); + } + } +}"; + + await VerifyAnalyzerAsync(source, ExpectedDiagnostic("Children", "'ChildrenIncludingDeleted' with an explicit filter")); + } + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_ExpressionVariable_ReportsDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public Expression> GetFilter() + { + return master => {|#0:master.Children|}.Any(); + } + } +}"; + + await VerifyAnalyzerAsync(source, ExpectedDiagnostic("Children", "'ChildrenIncludingDeleted' with an explicit filter")); + } + + /// + /// Kolekce bez namapovaného protějšku XIncludingDeleted - hláška nemá co navrhnout jménem. + /// + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_WithoutIncludingDeletedCounterpart_ReportsDiagnosticWithFallbackSuggestion() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IQueryable masters) + { + masters.Where(master => {|#0:master.Others|}.Any()); + } + } +}"; + + await VerifyAnalyzerAsync(source, ExpectedDiagnostic("Others", "the underlying mapped collection with an explicit filter")); + } + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_MappedCollection_DoesNotReportDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IQueryable masters) + { + masters.Where(master => master.ChildrenIncludingDeleted.Any(child => child.Deleted == null)); + } + } +}"; + + await VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_DataLoaderLoad_DoesNotReportDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IDataLoader dataLoader, Master master) + { + dataLoader.Load(master, item => item.Children); + } + } +}"; + + await VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_DataLoaderLoadAllAsync_DoesNotReportDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public async Task TestMethod(IDataLoader dataLoader, IEnumerable masters) + { + await dataLoader.LoadAllAsync(masters, item => item.Children); + } + } +}"; + + await VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_DataLoaderThenLoad_DoesNotReportDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IDataLoader dataLoader, IEnumerable children) + { + dataLoader.LoadAll(children, child => child.Master).ThenLoad(master => master.Children); + } + } +}"; + + await VerifyAnalyzerAsync(source); + } + + /// + /// Cizí API přijímající Expression (např. FluentValidation RuleFor) není dotaz do databáze - hlásit se nemá. + /// + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_ExpressionOutsideQuery_DoesNotReportDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod() + { + Validation.RuleFor>(master => master.Children); + } + } +}"; + + await VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_InMemoryLinq_DoesNotReportDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(List masters) + { + masters.Where(master => master.Children.Any()).ToList(); + } + } +}"; + + await VerifyAnalyzerAsync(source); + } + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_DirectAccess_DoesNotReportDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public int TestMethod(Master master) + { + return master.Children.Count; + } + } +}"; + + await VerifyAnalyzerAsync(source); + } + + private static DiagnosticResult ExpectedDiagnostic(string propertyName, string suggestion) + { + return new DiagnosticResult(Analyzers.Diagnostics.FilteringCollectionInExpressionTree) + .WithLocation(0) + .WithArguments(propertyName, suggestion); + } + + private static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + var test = new CSharpAnalyzerTest + { + TestState = + { + Sources = { source }, + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + }, + }; + + test.TestState.AdditionalReferences.Add(typeof(FilteringCollection<>).Assembly); + test.TestState.AdditionalReferences.Add(typeof(Data.Patterns.DataLoaders.IDataLoader).Assembly); + + test.ExpectedDiagnostics.AddRange(expected); + + await test.RunAsync(); + } +} diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/AnalyzerReleases.Unshipped.md b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/AnalyzerReleases.Unshipped.md index f2b7fad65..b71a33bf7 100644 --- a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/AnalyzerReleases.Unshipped.md +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,2 +1,8 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +HFW1004 | Usage | Warning | Diagnostics diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Diagnostics.cs b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Diagnostics.cs index 9f87c2f55..b048b3aae 100644 --- a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Diagnostics.cs +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Diagnostics.cs @@ -47,4 +47,23 @@ public static class Diagnostics isEnabledByDefault: true, description: "Detects when IEnumerable is passed to AddForInsert, AddForInsertAsync, AddForUpdate, or AddForDelete methods instead of a single entity." ); + + /// + /// Represents a diagnostic descriptor that identifies and reports cases where a member of type + /// FilteringCollection<T> is used within an expression tree which is translated to a database query. + /// + /// + /// FilteringCollection<T> is an in-memory wrapper over the underlying (mapped) collection, it is not + /// a mapped navigation property. Entity Framework Core is therefore not able to translate it to SQL: the query either + /// fails at runtime, or - when the member is used within the final projection - silently returns no data at all. + /// + public static readonly DiagnosticDescriptor FilteringCollectionInExpressionTree = new DiagnosticDescriptor( + id: DiagnosticIdentifiers.FilteringCollectionInExpressionTreeId, + title: "FilteringCollection used within a database query", + messageFormat: "'{0}' is a FilteringCollection which cannot be translated to SQL - the query fails at runtime or silently returns no data. Use {1} instead.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Detects when a FilteringCollection member is used within an expression tree (LINQ to Entities query). FilteringCollection is an in-memory wrapper which Entity Framework Core cannot translate to SQL." + ); } diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionConstants.cs b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionConstants.cs new file mode 100644 index 000000000..8c60a61e2 --- /dev/null +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionConstants.cs @@ -0,0 +1,20 @@ +namespace Havit.Data.EntityFrameworkCore.Patterns.Analyzers.FilteringCollections; + +internal static class FilteringCollectionConstants +{ + internal const string FilteringCollectionTypeName = "FilteringCollection"; + internal const string FilteringCollectionTypeNamespace = "Havit.Model.Collections.Generic"; + + internal const string IncludingDeletedSuffix = "IncludingDeleted"; + + internal const string ExpressionTypeName = "Expression"; + internal const string ExpressionTypeNamespace = "System.Linq.Expressions"; + + internal const string QueryableInterfaceName = "IQueryable"; + internal const string QueryableInterfaceNamespace = "System.Linq"; + + internal const string DataLoaderNamespace = "Havit.Data.Patterns.DataLoaders"; + internal const string DataLoaderInterfaceName = "IDataLoader"; + internal const string FluentDataLoaderInterfaceName = "IFluentDataLoader"; + internal const string FluentDataLoaderExtensionsTypeName = "FluentDataLoaderExtensions"; +} diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzer.cs b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzer.cs new file mode 100644 index 000000000..e354e96f1 --- /dev/null +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzer.cs @@ -0,0 +1,239 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Havit.Data.EntityFrameworkCore.Patterns.Analyzers.FilteringCollections; + +/// +/// Analyzer that detects usages of FilteringCollection<T> members within expression trees (LINQ to Entities queries). +/// +/// +/// FilteringCollection<T> is an in-memory wrapper over the underlying (mapped) collection, therefore Entity Framework Core +/// cannot translate it to SQL. Such a query either fails at runtime, or - when the member is used within the final projection - +/// silently returns no data at all. +/// +/// Data loaders (IDataLoader, IFluentDataLoader) are excluded: they do support FilteringCollection<T> +/// by substituting the XIncludingDeleted collection. Expression trees consumed outside of a database query +/// (validation rules, mocking setups, ...) have the shape entity => entity.Collection; that shape is reported only when +/// the expression is passed to an IQueryable method (e.g. Include). +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class FilteringCollectionInExpressionTreeAnalyzer : DiagnosticAnalyzer +{ + /// + public override ImmutableArray SupportedDiagnostics => [Diagnostics.FilteringCollectionInExpressionTree]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeLambda, SyntaxKind.SimpleLambdaExpression, SyntaxKind.ParenthesizedLambdaExpression); + } + + private void AnalyzeLambda(SyntaxNodeAnalysisContext context) + { + var lambda = (LambdaExpressionSyntax)context.Node; + + if (!IsExpressionTree(lambda, context.SemanticModel, context.CancellationToken)) + { + return; + } + + // Nested expression tree lambdas are already covered by the analysis of the outermost one. + if (lambda.Ancestors().OfType().Any(ancestor => IsExpressionTree(ancestor, context.SemanticModel, context.CancellationToken))) + { + return; + } + + InvocationExpressionSyntax enclosingInvocation = GetEnclosingInvocation(lambda); + if ((enclosingInvocation != null) && IsDataLoaderInvocation(enclosingInvocation, context.SemanticModel, context.CancellationToken)) + { + return; + } + + SyntaxNode lambdaBody = (lambda.Body is ExpressionSyntax bodyExpression) ? Unparenthesize(bodyExpression) : null; + + foreach (MemberAccessExpressionSyntax memberAccess in lambda.Body.DescendantNodesAndSelf().OfType()) + { + ISymbol memberSymbol = context.SemanticModel.GetSymbolInfo(memberAccess, context.CancellationToken).Symbol; + ITypeSymbol memberType = GetMemberType(memberSymbol); + if ((memberType == null) || !TryGetFilteringCollectionItemType(memberType, out ITypeSymbol itemType)) + { + continue; + } + + // entity => entity.Collection - the shape used by data loaders, validation rules or mocking setups. + // Within a query (Include, Select, ...) it is still an error, anywhere else it is a legitimate usage. + if ((memberAccess == lambdaBody) + && ((enclosingInvocation == null) || !IsQueryableInvocation(enclosingInvocation, context.SemanticModel, context.CancellationToken))) + { + continue; + } + + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.FilteringCollectionInExpressionTree, + memberAccess.GetLocation(), + memberSymbol.Name, + GetSuggestion(memberSymbol, itemType))); + } + } + + /// + /// Returns true when the lambda is converted to System.Linq.Expressions.Expression (an expression tree), not to a delegate. + /// + private static bool IsExpressionTree(LambdaExpressionSyntax lambda, SemanticModel semanticModel, CancellationToken cancellationToken) + { + ITypeSymbol convertedType = semanticModel.GetTypeInfo(lambda, cancellationToken).ConvertedType; + + for (ITypeSymbol type = convertedType; type != null; type = type.BaseType) + { + if ((type.Name == FilteringCollectionConstants.ExpressionTypeName) + && (type.ContainingNamespace?.ToDisplayString() == FilteringCollectionConstants.ExpressionTypeNamespace)) + { + return true; + } + } + + return false; + } + + private static ExpressionSyntax Unparenthesize(ExpressionSyntax expression) + { + while (expression is ParenthesizedExpressionSyntax parenthesized) + { + expression = parenthesized.Expression; + } + + return expression; + } + + private static InvocationExpressionSyntax GetEnclosingInvocation(LambdaExpressionSyntax lambda) + { + return ((lambda.Parent is ArgumentSyntax argument) && (argument.Parent is ArgumentListSyntax argumentList)) + ? argumentList.Parent as InvocationExpressionSyntax + : null; + } + + private static bool IsDataLoaderInvocation(InvocationExpressionSyntax invocation, SemanticModel semanticModel, CancellationToken cancellationToken) + { + if (semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + INamedTypeSymbol containingType = (methodSymbol.ReducedFrom ?? methodSymbol).ContainingType; + if (containingType == null) + { + return false; + } + + return IsDataLoaderType(containingType) || containingType.AllInterfaces.Any(IsDataLoaderType); + } + + private static bool IsDataLoaderType(INamedTypeSymbol type) + { + if (type.ContainingNamespace?.ToDisplayString() != FilteringCollectionConstants.DataLoaderNamespace) + { + return false; + } + + return (type.Name == FilteringCollectionConstants.DataLoaderInterfaceName) + || (type.Name == FilteringCollectionConstants.FluentDataLoaderInterfaceName) + || (type.Name == FilteringCollectionConstants.FluentDataLoaderExtensionsTypeName); + } + + private static bool IsQueryableInvocation(InvocationExpressionSyntax invocation, SemanticModel semanticModel, CancellationToken cancellationToken) + { + if (semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + if (IsQueryable(methodSymbol.ReceiverType) || IsQueryable(methodSymbol.ReturnType)) + { + return true; + } + + // non-reduced form of an extension method: Queryable.Include(source, navigationPropertyPath) + return (methodSymbol.Parameters.Length > 0) && IsQueryable(methodSymbol.Parameters[0].Type); + } + + private static bool IsQueryable(ITypeSymbol type) + { + if (type == null) + { + return false; + } + + return IsQueryableInterface(type) || ((type is INamedTypeSymbol namedType) && namedType.AllInterfaces.Any(IsQueryableInterface)); + } + + private static bool IsQueryableInterface(ITypeSymbol type) + { + return (type.Name == FilteringCollectionConstants.QueryableInterfaceName) + && (type.ContainingNamespace?.ToDisplayString() == FilteringCollectionConstants.QueryableInterfaceNamespace); + } + + private static ITypeSymbol GetMemberType(ISymbol symbol) + { + return symbol switch + { + IPropertySymbol property => property.Type, + IFieldSymbol field => field.Type, + _ => null + }; + } + + private static bool TryGetFilteringCollectionItemType(ITypeSymbol type, out ITypeSymbol itemType) + { + for (ITypeSymbol currentType = type; currentType != null; currentType = currentType.BaseType) + { + if ((currentType.Name == FilteringCollectionConstants.FilteringCollectionTypeName) + && (currentType.ContainingNamespace?.ToDisplayString() == FilteringCollectionConstants.FilteringCollectionTypeNamespace) + && (currentType is INamedTypeSymbol namedType) + && (namedType.TypeArguments.Length == 1)) + { + itemType = namedType.TypeArguments[0]; + return true; + } + } + + itemType = null; + return false; + } + + /// + /// Returns the name of the mapped counterpart (XIncludingDeleted) when the entity declares one - the very same substitution + /// the data loader does. Falls back to a generic wording when there is no such member. + /// + private static string GetSuggestion(ISymbol memberSymbol, ITypeSymbol itemType) + { + string expectedName = memberSymbol.Name + FilteringCollectionConstants.IncludingDeletedSuffix; + + for (INamedTypeSymbol type = memberSymbol.ContainingType; type != null; type = type.BaseType) + { + foreach (ISymbol candidate in type.GetMembers(expectedName)) + { + if ((GetMemberType(candidate) is INamedTypeSymbol candidateType) && IsEnumerableOf(candidateType, itemType)) + { + return $"'{expectedName}' with an explicit filter"; + } + } + } + + return "the underlying mapped collection with an explicit filter"; + } + + private static bool IsEnumerableOf(INamedTypeSymbol type, ITypeSymbol itemType) + { + return type.AllInterfaces.Concat([type]).Any(candidateType => + (candidateType.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T) + && SymbolEqualityComparer.Default.Equals(candidateType.TypeArguments.FirstOrDefault(), itemType)); + } +} From 542136ad8c75f2b826dcaba065743b42d6c362ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clavek=2C=20Ond=C5=99ej?= Date: Fri, 28 Aug 2026 15:37:19 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Analyzers:=20bal=C3=AD=C4=8Dek=20bez=20z?= =?UTF-8?q?=C3=A1vislost=C3=AD=20+=20release=20tracking=20+=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Balíček Havit.Data.EntityFrameworkCore.Patterns.Analyzers nesl závislosti, které do vývojové závislosti s analyzery nepatří: - ProjectReference na Havit.Core: analyzer z něj nic nepoužívá (jen linkuje DiagnosticsIdentifiers.cs), ale pack z něj dělá závislost balíčku, - nepoužívaný Microsoft.CodeAnalysis.CSharp.Workspaces bez PrivateAssets, tedy rovněž závislost balíčku. Dále přidán Microsoft.CodeAnalysis.Analyzers (kontroluje release tracking pravidel, tj. AnalyzerReleases.*.md) a README popisující pravidla HFW1002-HFW1004. PackageVersion 2.10.1 -> 2.10.2 (nové pravidlo HFW1004). --- ...ityFrameworkCore.Patterns.Analyzers.csproj | 8 ++----- .../README.md | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 Havit.Data.EntityFrameworkCore.Patterns.Analyzers/README.md diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.csproj b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.csproj index e83d2b829..3e01ddbb9 100644 --- a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.csproj +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.csproj @@ -17,7 +17,7 @@ - 2.10.1 + 2.10.2 false true @@ -26,14 +26,10 @@ + - - - - - diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/README.md b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/README.md new file mode 100644 index 000000000..dcd08b93a --- /dev/null +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/README.md @@ -0,0 +1,21 @@ +HAVIT .NET Framework Extensions - Entity Framework Core Data Patterns - Analyzers + +## Účel nuget balíčku +* Balíček obsahuje Roslyn analyzery, které v compile time hlásí chybná použití API knihovny + `Havit.Data.EntityFrameworkCore.Patterns` (resp. `Havit.Data.Patterns`). +* Jde o vývojovou závislost (`DevelopmentDependency`), do runtime se nic nepřenáší. + +## Jak balíček použít +* Zaregistrujte nuget balíček `Havit.Data.EntityFrameworkCore.Patterns.Analyzers` do projektů, + ve kterých se pracuje s `IUnitOfWork`, s modelem a s dotazy do databáze + (typicky `Model`, `DataLayer`, `Services`, `Facades`). +* Jednotlivá pravidla lze standardně konfigurovat v `.editorconfig` + (např. `dotnet_diagnostic.HFW1004.severity = error`). + +## Pravidla + +| ID | Pravidlo | +| --- | --- | +| HFW1002 | `IEnumerable` předaný do `IUnitOfWork.AddFor*` metody, která očekává jednu entitu. | +| HFW1003 | Vnořená kolekce (`IEnumerable>`) předaná do `IUnitOfWork.AddRangeFor*` metody. | +| HFW1004 | `FilteringCollection` použitá v expression tree, tedy v dotazu do databáze. Kolekce je in-memory wrapper nad namapovanou kolekcí, EF Core ji nepřeloží do SQL: dotaz buď spadne za běhu, nebo (ve finální projekci) tiše nevrátí žádná data. Řešením je použít namapovanou kolekci `XIncludingDeleted` s explicitním filtrem. Načítání přes `IDataLoader`/`IFluentDataLoader` hlášeno není - data loader `FilteringCollection` podporuje. | From b1a9663d165252abeca6f851eb1b1cdc0e9c81b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clavek=2C=20Ond=C5=99ej?= Date: Fri, 28 Aug 2026 16:12:20 +0200 Subject: [PATCH 3/3] =?UTF-8?q?HFW1004:=20review=20fixes=20-=20p=C5=99epis?= =?UTF-8?q?=20analyzeru=20na=20IOperation=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pokrytí query syntax (falešné negativum čistě syntaxového přístupu) - compilation-start gate + porovnávání symbolů přes SymbolEqualityComparer - SupportedDiagnostics bez alokace při každém přístupu - křížové komentáře ke zduplikované konvenci sufixu IncludingDeleted - nové testy: query syntax (IQueryable i in-memory), deduplikace vnořené quoted lambdy, návrh XIncludingDeleted přes dědičnost, data loader s ne-celotělovou lambdou a params overloadem - PackageVersion 2.10.2 -> 2.11.0 (nové pravidlo s default severitou Warning je minor, ne patch) Co-Authored-By: Claude Fable 5 --- ...CollectionInExpressionTreeAnalyzerTests.cs | 143 +++++++++++ .../FilteringCollectionConstants.cs | 22 +- ...eringCollectionInExpressionTreeAnalyzer.cs | 226 +++++++++++------- ...ityFrameworkCore.Patterns.Analyzers.csproj | 2 +- .../README.md | 2 +- ...DeletedFilteringCollectionsSubstitution.cs | 2 + 6 files changed, 298 insertions(+), 99 deletions(-) diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.Tests/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzerTests.cs b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.Tests/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzerTests.cs index 167d43fef..c0836426b 100644 --- a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.Tests/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzerTests.cs +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.Tests/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzerTests.cs @@ -46,6 +46,21 @@ public Master() } } + public class MasterBase + { + public List ItemsIncludingDeleted { get; } = new List(); + } + + public class DerivedMaster : MasterBase + { + public FilteringCollection Items { get; } + + public DerivedMaster() + { + Items = new FilteringCollection(ItemsIncludingDeleted, child => child.Deleted == null); + } + } + public static class QueryableExtensions { public static IQueryable Include(this IQueryable source, Expression> navigationPropertyPath) => source; @@ -303,6 +318,134 @@ public int TestMethod(Master master) await VerifyAnalyzerAsync(source); } + /// + /// Query syntax neobsahuje žádný lambda syntax node - dotaz je na lambdy přeložený až v operation tree. + /// + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_QuerySyntax_ReportsDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IQueryable masters) + { + var result = from master in masters + where {|#0:master.Children|}.Any() + select master; + } + } +}"; + + await VerifyAnalyzerAsync(source, ExpectedDiagnostic("Children", "'ChildrenIncludingDeleted' with an explicit filter")); + } + + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_QuerySyntaxOverInMemoryCollection_DoesNotReportDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(List masters) + { + var result = from master in masters + where master.Children.Any() + select master; + } + } +}"; + + await VerifyAnalyzerAsync(source); + } + + /// + /// Vnořená (quoted) lambda uvnitř outer expression tree - diagnostika se musí hlásit právě jednou. + /// + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_NestedQuotedLambda_ReportsDiagnosticOnce() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IQueryable masters) + { + masters.Where(master => masters.Any(other => {|#0:master.Children|}.Any())); + } + } +}"; + + await VerifyAnalyzerAsync(source, ExpectedDiagnostic("Children", "'ChildrenIncludingDeleted' with an explicit filter")); + } + + /// + /// Namapovaný protějšek XIncludingDeleted deklarovaný na bázové entitě - návrh ho musí najít přes dědičnost. + /// + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_IncludingDeletedCounterpartOnBaseType_ReportsDiagnosticWithSuggestion() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IQueryable masters) + { + masters.Where(master => {|#0:master.Items|}.Any()); + } + } +}"; + + await VerifyAnalyzerAsync(source, ExpectedDiagnostic("Items", "'ItemsIncludingDeleted' with an explicit filter")); + } + + /// + /// Lambda předaná data loaderu se přeskakuje celá, bez ohledu na tvar (delší property path by data loader + /// odmítl za běhu vlastní - srozumitelnou - výjimkou, analyzer ji neřeší). + /// + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_DataLoaderWithNonWholeBodyLambda_DoesNotReportDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IDataLoader dataLoader, Master master) + { + dataLoader.Load(master, item => item.Children.First().Master); + } + } +}"; + + await VerifyAnalyzerAsync(source); + } + + /// + /// Params overload data loaderu - lambda je v operation tree zabalená do pole (ArrayCreation/ArrayInitializer). + /// + [TestMethod] + public async Task FilteringCollectionInExpressionTreeAnalyzer_DataLoaderParamsOverload_DoesNotReportDiagnostic() + { + const string source = ModelDeclarations + @" +namespace TestNamespace +{ + public class TestClass + { + public void TestMethod(IDataLoader dataLoader, Master master) + { + dataLoader.Load(master, item => item.Children, item => item.ChildrenIncludingDeleted); + } + } +}"; + + await VerifyAnalyzerAsync(source); + } + private static DiagnosticResult ExpectedDiagnostic(string propertyName, string suggestion) { return new DiagnosticResult(Analyzers.Diagnostics.FilteringCollectionInExpressionTree) diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionConstants.cs b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionConstants.cs index 8c60a61e2..8a59ba67c 100644 --- a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionConstants.cs +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionConstants.cs @@ -1,20 +1,20 @@ -namespace Havit.Data.EntityFrameworkCore.Patterns.Analyzers.FilteringCollections; +namespace Havit.Data.EntityFrameworkCore.Patterns.Analyzers.FilteringCollections; internal static class FilteringCollectionConstants { - internal const string FilteringCollectionTypeName = "FilteringCollection"; - internal const string FilteringCollectionTypeNamespace = "Havit.Model.Collections.Generic"; + internal const string FilteringCollectionMetadataName = "Havit.Model.Collections.Generic.FilteringCollection`1"; + // Konvence pojmenování namapovaného protějšku (X -> XIncludingDeleted) je zduplikovaná vůči runtime substituci + // v PropertyLoadSequenceResolverIncludingDeletedFilteringCollectionsSubstitution (Havit.Data.EntityFrameworkCore.Patterns). + // Analyzer projekt na Patterns referencovat nemůže. Při změně konvence je potřeba upravit obě místa. internal const string IncludingDeletedSuffix = "IncludingDeleted"; - internal const string ExpressionTypeName = "Expression"; - internal const string ExpressionTypeNamespace = "System.Linq.Expressions"; + internal const string ExpressionOfTDelegateMetadataName = "System.Linq.Expressions.Expression`1"; - internal const string QueryableInterfaceName = "IQueryable"; - internal const string QueryableInterfaceNamespace = "System.Linq"; + internal const string QueryableMetadataName = "System.Linq.IQueryable"; + internal const string QueryableOfTMetadataName = "System.Linq.IQueryable`1"; - internal const string DataLoaderNamespace = "Havit.Data.Patterns.DataLoaders"; - internal const string DataLoaderInterfaceName = "IDataLoader"; - internal const string FluentDataLoaderInterfaceName = "IFluentDataLoader"; - internal const string FluentDataLoaderExtensionsTypeName = "FluentDataLoaderExtensions"; + internal const string DataLoaderMetadataName = "Havit.Data.Patterns.DataLoaders.IDataLoader"; + internal const string FluentDataLoaderMetadataName = "Havit.Data.Patterns.DataLoaders.IFluentDataLoader`1"; + internal const string FluentDataLoaderExtensionsMetadataName = "Havit.Data.Patterns.DataLoaders.FluentDataLoaderExtensions"; } diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzer.cs b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzer.cs index e354e96f1..7be988af9 100644 --- a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzer.cs +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/FilteringCollections/FilteringCollectionInExpressionTreeAnalyzer.cs @@ -1,9 +1,8 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using System.Linq; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; namespace Havit.Data.EntityFrameworkCore.Patterns.Analyzers.FilteringCollections; @@ -15,6 +14,11 @@ namespace Havit.Data.EntityFrameworkCore.Patterns.Analyzers.FilteringCollections /// cannot translate it to SQL. Such a query either fails at runtime, or - when the member is used within the final projection - /// silently returns no data at all. /// +/// The analysis is operation-based (/), +/// so it covers both method syntax and query syntax - query clauses are already lowered to expression tree lambdas +/// in the operation tree. +/// +/// /// Data loaders (IDataLoader, IFluentDataLoader) are excluded: they do support FilteringCollection<T> /// by substituting the XIncludingDeleted collection. Expression trees consumed outside of a database query /// (validation rules, mocking setups, ...) have the shape entity => entity.Collection; that shape is reported only when @@ -24,8 +28,10 @@ namespace Havit.Data.EntityFrameworkCore.Patterns.Analyzers.FilteringCollections [DiagnosticAnalyzer(LanguageNames.CSharp)] public class FilteringCollectionInExpressionTreeAnalyzer : DiagnosticAnalyzer { + private static readonly ImmutableArray supportedDiagnostics = ImmutableArray.Create(Diagnostics.FilteringCollectionInExpressionTree); + /// - public override ImmutableArray SupportedDiagnostics => [Diagnostics.FilteringCollectionInExpressionTree]; + public override ImmutableArray SupportedDiagnostics => supportedDiagnostics; /// public override void Initialize(AnalysisContext context) @@ -33,151 +39,164 @@ public override void Initialize(AnalysisContext context) context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); - context.RegisterSyntaxNodeAction(AnalyzeLambda, SyntaxKind.SimpleLambdaExpression, SyntaxKind.ParenthesizedLambdaExpression); + context.RegisterCompilationStartAction(compilationStartContext => + { + KnownTypes knownTypes = KnownTypes.TryResolve(compilationStartContext.Compilation); + if (knownTypes == null) + { + // The compilation does not reference FilteringCollection at all - do not register any action. + return; + } + + compilationStartContext.RegisterOperationAction( + operationContext => AnalyzeMemberReference(operationContext, knownTypes), + OperationKind.PropertyReference, + OperationKind.FieldReference); + }); } - private void AnalyzeLambda(SyntaxNodeAnalysisContext context) + private static void AnalyzeMemberReference(OperationAnalysisContext context, KnownTypes knownTypes) { - var lambda = (LambdaExpressionSyntax)context.Node; + var memberReference = (IMemberReferenceOperation)context.Operation; - if (!IsExpressionTree(lambda, context.SemanticModel, context.CancellationToken)) + if (!TryGetFilteringCollectionItemType(memberReference.Type, knownTypes, out ITypeSymbol itemType)) { return; } - // Nested expression tree lambdas are already covered by the analysis of the outermost one. - if (lambda.Ancestors().OfType().Any(ancestor => IsExpressionTree(ancestor, context.SemanticModel, context.CancellationToken))) + // The member reference is relevant only when it sits inside a lambda converted to an expression tree. + // Each member reference is visited exactly once, so nested (quoted) lambdas need no deduplication; + // the outermost expression tree lambda determines the consumer (data loader, IQueryable method, ...). + IAnonymousFunctionOperation outermostExpressionTreeLambda = null; + for (IOperation current = memberReference.Parent; current != null; current = current.Parent) { - return; + if ((current is IAnonymousFunctionOperation anonymousFunction) && IsConvertedToExpressionTree(anonymousFunction, knownTypes)) + { + outermostExpressionTreeLambda = anonymousFunction; + } } - InvocationExpressionSyntax enclosingInvocation = GetEnclosingInvocation(lambda); - if ((enclosingInvocation != null) && IsDataLoaderInvocation(enclosingInvocation, context.SemanticModel, context.CancellationToken)) + if (outermostExpressionTreeLambda == null) { return; } - SyntaxNode lambdaBody = (lambda.Body is ExpressionSyntax bodyExpression) ? Unparenthesize(bodyExpression) : null; + IInvocationOperation enclosingInvocation = GetEnclosingInvocation(outermostExpressionTreeLambda); - foreach (MemberAccessExpressionSyntax memberAccess in lambda.Body.DescendantNodesAndSelf().OfType()) + if ((enclosingInvocation != null) && IsDataLoaderInvocation(enclosingInvocation, knownTypes)) { - ISymbol memberSymbol = context.SemanticModel.GetSymbolInfo(memberAccess, context.CancellationToken).Symbol; - ITypeSymbol memberType = GetMemberType(memberSymbol); - if ((memberType == null) || !TryGetFilteringCollectionItemType(memberType, out ITypeSymbol itemType)) - { - continue; - } - - // entity => entity.Collection - the shape used by data loaders, validation rules or mocking setups. - // Within a query (Include, Select, ...) it is still an error, anywhere else it is a legitimate usage. - if ((memberAccess == lambdaBody) - && ((enclosingInvocation == null) || !IsQueryableInvocation(enclosingInvocation, context.SemanticModel, context.CancellationToken))) - { - continue; - } + return; + } - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.FilteringCollectionInExpressionTree, - memberAccess.GetLocation(), - memberSymbol.Name, - GetSuggestion(memberSymbol, itemType))); + // entity => entity.Collection - the shape used by data loaders, validation rules or mocking setups. + // Within a query (Include, Select, ...) it is still an error, anywhere else it is a legitimate usage. + if (IsWholeLambdaBody(memberReference, outermostExpressionTreeLambda) + && ((enclosingInvocation == null) || !IsQueryableInvocation(enclosingInvocation, knownTypes))) + { + return; } + + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.FilteringCollectionInExpressionTree, + memberReference.Syntax.GetLocation(), + memberReference.Member.Name, + GetSuggestion(memberReference.Member, itemType))); } /// - /// Returns true when the lambda is converted to System.Linq.Expressions.Expression (an expression tree), not to a delegate. + /// Returns true when the anonymous function is converted to System.Linq.Expressions.Expression<TDelegate> + /// (an expression tree), not to a delegate. /// - private static bool IsExpressionTree(LambdaExpressionSyntax lambda, SemanticModel semanticModel, CancellationToken cancellationToken) + private static bool IsConvertedToExpressionTree(IAnonymousFunctionOperation anonymousFunction, KnownTypes knownTypes) { - ITypeSymbol convertedType = semanticModel.GetTypeInfo(lambda, cancellationToken).ConvertedType; + // An expression tree conversion is an IConversionOperation to Expression wrapping the anonymous function + // (IDelegateCreationOperation is used for conversions to a delegate type only). + return (anonymousFunction.Parent is IConversionOperation conversion) + && (conversion.Type is INamedTypeSymbol convertedType) + && SymbolEqualityComparer.Default.Equals(convertedType.OriginalDefinition, knownTypes.ExpressionOfTDelegate); + } - for (ITypeSymbol type = convertedType; type != null; type = type.BaseType) + /// + /// Returns true when the member reference (modulo implicit conversions) forms the whole body of the lambda, + /// i.e. the lambda has the shape entity => entity.Collection. + /// + private static bool IsWholeLambdaBody(IMemberReferenceOperation memberReference, IAnonymousFunctionOperation lambda) + { + IOperation current = memberReference; + while ((current.Parent is IConversionOperation conversion) && conversion.IsImplicit) { - if ((type.Name == FilteringCollectionConstants.ExpressionTypeName) - && (type.ContainingNamespace?.ToDisplayString() == FilteringCollectionConstants.ExpressionTypeNamespace)) - { - return true; - } + current = conversion; } - return false; + return (current.Parent is IReturnOperation returnOperation) && (returnOperation.Parent == lambda.Body); } - private static ExpressionSyntax Unparenthesize(ExpressionSyntax expression) + /// + /// Returns the invocation the lambda is passed to as an argument (incl. a params array of expressions), or null. + /// + private static IInvocationOperation GetEnclosingInvocation(IAnonymousFunctionOperation lambda) { - while (expression is ParenthesizedExpressionSyntax parenthesized) + IOperation current = lambda.Parent; + while (current is IDelegateCreationOperation or IConversionOperation or IArrayInitializerOperation or IArrayCreationOperation) { - expression = parenthesized.Expression; + current = current.Parent; } - return expression; - } - - private static InvocationExpressionSyntax GetEnclosingInvocation(LambdaExpressionSyntax lambda) - { - return ((lambda.Parent is ArgumentSyntax argument) && (argument.Parent is ArgumentListSyntax argumentList)) - ? argumentList.Parent as InvocationExpressionSyntax + return (current is IArgumentOperation argument) + ? argument.Parent as IInvocationOperation : null; } - private static bool IsDataLoaderInvocation(InvocationExpressionSyntax invocation, SemanticModel semanticModel, CancellationToken cancellationToken) + private static bool IsDataLoaderInvocation(IInvocationOperation invocation, KnownTypes knownTypes) { - if (semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is not IMethodSymbol methodSymbol) - { - return false; - } - + IMethodSymbol methodSymbol = invocation.TargetMethod; INamedTypeSymbol containingType = (methodSymbol.ReducedFrom ?? methodSymbol).ContainingType; if (containingType == null) { return false; } - return IsDataLoaderType(containingType) || containingType.AllInterfaces.Any(IsDataLoaderType); + return IsDataLoaderType(containingType, knownTypes) || containingType.AllInterfaces.Any(interfaceType => IsDataLoaderType(interfaceType, knownTypes)); } - private static bool IsDataLoaderType(INamedTypeSymbol type) + private static bool IsDataLoaderType(INamedTypeSymbol type, KnownTypes knownTypes) { - if (type.ContainingNamespace?.ToDisplayString() != FilteringCollectionConstants.DataLoaderNamespace) - { - return false; - } + INamedTypeSymbol typeDefinition = type.OriginalDefinition; - return (type.Name == FilteringCollectionConstants.DataLoaderInterfaceName) - || (type.Name == FilteringCollectionConstants.FluentDataLoaderInterfaceName) - || (type.Name == FilteringCollectionConstants.FluentDataLoaderExtensionsTypeName); + return SymbolEqualityComparer.Default.Equals(typeDefinition, knownTypes.DataLoader) + || SymbolEqualityComparer.Default.Equals(typeDefinition, knownTypes.FluentDataLoader) + || SymbolEqualityComparer.Default.Equals(typeDefinition, knownTypes.FluentDataLoaderExtensions); } - private static bool IsQueryableInvocation(InvocationExpressionSyntax invocation, SemanticModel semanticModel, CancellationToken cancellationToken) + private static bool IsQueryableInvocation(IInvocationOperation invocation, KnownTypes knownTypes) { - if (semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is not IMethodSymbol methodSymbol) - { - return false; - } + IMethodSymbol methodSymbol = invocation.TargetMethod; - if (IsQueryable(methodSymbol.ReceiverType) || IsQueryable(methodSymbol.ReturnType)) + if (IsQueryable(methodSymbol.ReceiverType, knownTypes) || IsQueryable(methodSymbol.ReturnType, knownTypes)) { return true; } // non-reduced form of an extension method: Queryable.Include(source, navigationPropertyPath) - return (methodSymbol.Parameters.Length > 0) && IsQueryable(methodSymbol.Parameters[0].Type); + return (methodSymbol.Parameters.Length > 0) && IsQueryable(methodSymbol.Parameters[0].Type, knownTypes); } - private static bool IsQueryable(ITypeSymbol type) + private static bool IsQueryable(ITypeSymbol type, KnownTypes knownTypes) { if (type == null) { return false; } - return IsQueryableInterface(type) || ((type is INamedTypeSymbol namedType) && namedType.AllInterfaces.Any(IsQueryableInterface)); + return IsQueryableInterface(type, knownTypes) || ((type is INamedTypeSymbol namedType) && namedType.AllInterfaces.Any(interfaceType => IsQueryableInterface(interfaceType, knownTypes))); } - private static bool IsQueryableInterface(ITypeSymbol type) + private static bool IsQueryableInterface(ITypeSymbol type, KnownTypes knownTypes) { - return (type.Name == FilteringCollectionConstants.QueryableInterfaceName) - && (type.ContainingNamespace?.ToDisplayString() == FilteringCollectionConstants.QueryableInterfaceNamespace); + ITypeSymbol typeDefinition = type.OriginalDefinition; + + return SymbolEqualityComparer.Default.Equals(typeDefinition, knownTypes.Queryable) + || SymbolEqualityComparer.Default.Equals(typeDefinition, knownTypes.QueryableOfT); } private static ITypeSymbol GetMemberType(ISymbol symbol) @@ -190,14 +209,12 @@ private static ITypeSymbol GetMemberType(ISymbol symbol) }; } - private static bool TryGetFilteringCollectionItemType(ITypeSymbol type, out ITypeSymbol itemType) + private static bool TryGetFilteringCollectionItemType(ITypeSymbol type, KnownTypes knownTypes, out ITypeSymbol itemType) { for (ITypeSymbol currentType = type; currentType != null; currentType = currentType.BaseType) { - if ((currentType.Name == FilteringCollectionConstants.FilteringCollectionTypeName) - && (currentType.ContainingNamespace?.ToDisplayString() == FilteringCollectionConstants.FilteringCollectionTypeNamespace) - && (currentType is INamedTypeSymbol namedType) - && (namedType.TypeArguments.Length == 1)) + if ((currentType is INamedTypeSymbol namedType) + && SymbolEqualityComparer.Default.Equals(namedType.OriginalDefinition, knownTypes.FilteringCollection)) { itemType = namedType.TypeArguments[0]; return true; @@ -236,4 +253,41 @@ private static bool IsEnumerableOf(INamedTypeSymbol type, ITypeSymbol itemType) (candidateType.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T) && SymbolEqualityComparer.Default.Equals(candidateType.TypeArguments.FirstOrDefault(), itemType)); } + + /// + /// Symbols of well-known types, resolved once per compilation. Data loader types may be null (the compilation + /// does not have to reference Havit.Data.Patterns) - never matches null. + /// + private sealed class KnownTypes + { + public INamedTypeSymbol FilteringCollection { get; private set; } + public INamedTypeSymbol ExpressionOfTDelegate { get; private set; } + public INamedTypeSymbol Queryable { get; private set; } + public INamedTypeSymbol QueryableOfT { get; private set; } + public INamedTypeSymbol DataLoader { get; private set; } + public INamedTypeSymbol FluentDataLoader { get; private set; } + public INamedTypeSymbol FluentDataLoaderExtensions { get; private set; } + + public static KnownTypes TryResolve(Compilation compilation) + { + INamedTypeSymbol filteringCollection = compilation.GetTypeByMetadataName(FilteringCollectionConstants.FilteringCollectionMetadataName); + INamedTypeSymbol expressionOfTDelegate = compilation.GetTypeByMetadataName(FilteringCollectionConstants.ExpressionOfTDelegateMetadataName); + + if ((filteringCollection == null) || (expressionOfTDelegate == null)) + { + return null; + } + + return new KnownTypes + { + FilteringCollection = filteringCollection, + ExpressionOfTDelegate = expressionOfTDelegate, + Queryable = compilation.GetTypeByMetadataName(FilteringCollectionConstants.QueryableMetadataName), + QueryableOfT = compilation.GetTypeByMetadataName(FilteringCollectionConstants.QueryableOfTMetadataName), + DataLoader = compilation.GetTypeByMetadataName(FilteringCollectionConstants.DataLoaderMetadataName), + FluentDataLoader = compilation.GetTypeByMetadataName(FilteringCollectionConstants.FluentDataLoaderMetadataName), + FluentDataLoaderExtensions = compilation.GetTypeByMetadataName(FilteringCollectionConstants.FluentDataLoaderExtensionsMetadataName), + }; + } + } } diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.csproj b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.csproj index 3e01ddbb9..71aa8b13a 100644 --- a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.csproj +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/Havit.Data.EntityFrameworkCore.Patterns.Analyzers.csproj @@ -17,7 +17,7 @@ - 2.10.2 + 2.11.0 false true diff --git a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/README.md b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/README.md index dcd08b93a..2ad286a4d 100644 --- a/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/README.md +++ b/Havit.Data.EntityFrameworkCore.Patterns.Analyzers/README.md @@ -18,4 +18,4 @@ | --- | --- | | HFW1002 | `IEnumerable` předaný do `IUnitOfWork.AddFor*` metody, která očekává jednu entitu. | | HFW1003 | Vnořená kolekce (`IEnumerable>`) předaná do `IUnitOfWork.AddRangeFor*` metody. | -| HFW1004 | `FilteringCollection` použitá v expression tree, tedy v dotazu do databáze. Kolekce je in-memory wrapper nad namapovanou kolekcí, EF Core ji nepřeloží do SQL: dotaz buď spadne za běhu, nebo (ve finální projekci) tiše nevrátí žádná data. Řešením je použít namapovanou kolekci `XIncludingDeleted` s explicitním filtrem. Načítání přes `IDataLoader`/`IFluentDataLoader` hlášeno není - data loader `FilteringCollection` podporuje. | +| HFW1004 | `FilteringCollection` použitá v expression tree, tedy v dotazu do databáze. Kolekce je in-memory wrapper nad namapovanou kolekcí, EF Core ji nepřeloží do SQL: dotaz buď spadne za běhu, nebo (ve finální projekci) tiše nevrátí žádná data. Řešením je použít namapovanou kolekci `XIncludingDeleted` s explicitním filtrem. Pokrývá method syntax i query syntax. Načítání přes `IDataLoader`/`IFluentDataLoader` hlášeno není - data loader `FilteringCollection` podporuje. | diff --git a/Havit.Data.EntityFrameworkCore.Patterns/DataLoaders/Internal/PropertyLoadSequenceResolverIncludingDeletedFilteringCollectionsSubstitution.cs b/Havit.Data.EntityFrameworkCore.Patterns/DataLoaders/Internal/PropertyLoadSequenceResolverIncludingDeletedFilteringCollectionsSubstitution.cs index 4a71e9e03..e4600fcbb 100644 --- a/Havit.Data.EntityFrameworkCore.Patterns/DataLoaders/Internal/PropertyLoadSequenceResolverIncludingDeletedFilteringCollectionsSubstitution.cs +++ b/Havit.Data.EntityFrameworkCore.Patterns/DataLoaders/Internal/PropertyLoadSequenceResolverIncludingDeletedFilteringCollectionsSubstitution.cs @@ -19,6 +19,8 @@ public override PropertyToLoad[] GetPropertiesToLoad(Express { // pokud jde o kolekci // a existuje vlastnost s pojmenováním "IncludingDeleted" na konci + // (konvence sufixu je zduplikovaná v analyzeru HFW1004 - Havit.Data.EntityFrameworkCore.Patterns.Analyzers, + // FilteringCollectionConstants.IncludingDeletedSuffix; při změně konvence je potřeba upravit obě místa) // která obsahuje prvky stejného typu // pak provedeme substituci if (propertyToLoad.IsCollection)