diff --git a/docs/LC045_MissingInclude.md b/docs/LC045_MissingInclude.md index 1db6117..b9ac483 100644 --- a/docs/LC045_MissingInclude.md +++ b/docs/LC045_MissingInclude.md @@ -49,7 +49,7 @@ The fix only registers when the source expression it would wrap is statically `I 1. **Anchor**: register on entity-producing materializers — `ToList`/`ToArray`/`ToHashSet` (+ supported `Async` forms), `First`/`Single`/`Last` (`OrDefault`, `Async`), and query-root `ElementAt` (`OrDefault`, supported `Async`) — plus synchronous `foreach` directly over a statically `IQueryable` DbSet-rooted source. Inline collection materializers use the same source proof. Aggregates (`Count`, `Any`, …) never materialize entities and are ignored. 2. **Chain proof**: walk the semantic source parameter back to a `DbSet` property/field on a `DbContext`, or to `DbContext.Set()`. Exact `Queryable`, EF, and relational symbols preserve only known query shapes, including `AsQueryable`, `IgnoreAutoIncludes`, and EF Core `FromSql*`; reordered static arguments are resolved by parameter ordinal. Anything else — `Select`, `Join`, `GroupBy`, custom extensions, and lookalikes — bails. 3. **Included paths**: parse every `Include`/`ThenInclude` (lambda, filtered-lambda, and constant-string overloads) into navigation paths and record every prefix (`Include(o => o.A.B)` covers `A` and `A.B`). If any Include cannot be parsed (dynamic string), the whole query is skipped — it could cover anything. -4. **Model-level eager loading**: cache exact top-level `OnModelCreating` overrides per context, matching constructed generic contexts through their original symbols. An exact EF `modelBuilder.Entity().Navigation(e => e.Nav).AutoInclude()` chain counts as loading only that entity/navigation path. Later fluent or standalone disablement removes the proof, an unproven later model-configuration boundary invalidates prior evidence, and a query-level `IgnoreAutoIncludes()` disables all model-level evidence for that query. +4. **Model-level eager loading**: cache exact top-level `OnModelCreating` overrides per context, matching constructed generic contexts through their original symbols. An exact EF `modelBuilder.Entity().Navigation(e => e.Nav).AutoInclude()` chain counts as loading only that entity/navigation path. The same proof follows an unconditional `modelBuilder.ApplyConfiguration(new TConfiguration())` into the source-visible implementation of the exact `IEntityTypeConfiguration.Configure` method, where direct top-level `builder.Navigation(...).AutoInclude()` settings are applied in execution order. Later fluent, standalone, or applied-configuration disablement removes the proof; conditional/runtime settings and unknown builder-consuming helpers invalidate it. A query-level `IgnoreAutoIncludes()` disables all model-level evidence for that query. 5. **Navigation classification**: a property is a navigation when its type (or collection element type) has a `DbSet` on the same context. Owned and unmapped types have no `DbSet` and are never flagged. 6. **Usage scan**: build and cache the Roslyn CFG for the containing method or constructor, then analyse forward from the materializer or direct loop source. Track entity-bearing locals — collection results, `foreach` iteration variables, indexer-initialized locals, exact `System.Linq.Enumerable` element extraction (`First*`, `Single*`, `Last*`, `ElementAt*`) from a materialized collection, aliases, and locals extracted from reference navigations — by origin, binding generation, and navigation prefix. Nested collection iteration carries that prefix (`order.Items` then `item.Product` → `Items.Product`). At joins, keep only identical bindings, retain navigation writes that occurred on every incoming path, and treat an escape or uncertain reassignment on any incoming path as uncertainty for subsequent reads. Exact `List.ForEach` and single-source `Enumerable.Where`/`Select`/`Any`/`All` inline callbacks receive their own nested CFG only while the original materialized collection generation is proven active at the call. `Where` forwards provenance only through an effect-free inline predicate, and scalar `Select` projections do not poison later ordinary reads; entity-returning projections, arbitrary callbacks, and delegate/method-group forms remain boundaries. Direct property-subpattern reads use the same navigation-path and dominance proof. 7. **Emit**: one diagnostic per distinct missing navigation path, at the first access site, carrying the exact query source location and the dotted path for the code fix. Only maximal paths are reported — fixing `Customer.Address` eagerly loads `Customer` too. @@ -60,14 +60,14 @@ The fix only registers when the source expression it would wrap is statically `I - Reassigning a result local or repointing an entity local similarly suppresses only subsequent reads whose origin is no longer proven. If only one control-flow branch escapes or repoints the value, the merged origin is uncertain and stays quiet afterward. - Navigation writes are not reads: `o.Customer = c` (including compound, `??=`, and deconstruction assignments) and `o.Items.Add(x)` are recognized relationship-fix-up patterns and stay quiet. A navigation write satisfies a later read only for the same entity origin and only when every path reaching that read performs the write; a one-branch write or a write to a different extracted entity does not suppress the diagnostic. - Mid-path casts and null-forgiving operators in Include lambdas (`Include(o => o.Customer!.Address)`, `Include(o => ((Derived)o.Nav).Child)`) parse as the full path; an Include shape the parser cannot prove silences the whole query. -- Model-level eager-loading evidence is context- and path-scoped. Conditional, deferred, runtime-valued, or early-exit-guarded `AutoInclude()` calls, shadowed or hidden-slot `OnModelCreating` lookalikes, fluent or standalone `AutoInclude(false)`, later base/helper/indirect configuration boundaries, and configuration belonging to another context or navigation never suppress LC045. +- Model-level eager-loading evidence is context- and path-scoped. Conditional, deferred, runtime-valued, or early-exit-guarded `AutoInclude()` calls, shadowed or hidden-slot `OnModelCreating` lookalikes, fluent, standalone, or applied-configuration `AutoInclude(false)`, later base/helper/indirect configuration boundaries, and configuration belonging to another context, entity, or navigation never suppress LC045. Ordinary exact EF builder calls such as `HasKey` and relational mapping extensions do not invalidate an otherwise direct configuration proof. - `nameof(o.Customer)` evaluates nothing and is never flagged. - Properties whose type has no `DbSet` (owned/unmapped types) are never navigations. - Non-EF sources (`List` LINQ) never match the DbSet root proof. ## Deliberate Decisions & Known Limits - **Null-guarded access still fires.** `if (o.Customer != null)` and `order?.Customer` are flagged on purpose: with proxies the null check itself can trigger the N+1 load, and without another loading mechanism a consistently null navigation makes the guard dead code hiding the bug. Suppress with `#pragma warning disable LC045` if the guard is intentional. This holds for every null-conditional spelling: chained inline access on the materializer (`FirstOrDefault()?.Customer?.Name`, `FirstOrDefault()?.Customer.Address?.City`), parenthesized regrouping (`(order?.Customer)?.Address?.City`, reported as `Customer.Address`, including inline materializer and inherited-navigation forms), conditional element access on the result (`orders?[0].Customer`), and locals initialized from a conditional indexer (`var o = orders?[0];`). Conditional method-call results such as `(order?.Customer.GetDetached())?.Address` are treated as call results, not as a continuation of the queried navigation path. -- Exact top-level `AutoInclude()` chains in the nearest real `OnModelCreating` override are recognised, including constructed generic contexts. Indirect builder locals, `IEntityTypeConfiguration`/`ApplyConfiguration`, inferred base-method calls, deferred calls, later unproven model mutations, and transitive auto-includes on a different entity remain unproven and can still report; keep an explicit `Include`, project, or use a reviewed suppression for those shapes. +- Exact top-level `AutoInclude()` chains in the nearest real `OnModelCreating` override are recognised, including constructed generic contexts. Exact unconditional `ApplyConfiguration(new TConfiguration())` calls are also recognised when the source-visible `IEntityTypeConfiguration.Configure` implementation contains direct builder chains. Configuration instances supplied through locals, fields, factories, assembly scanning, builder aliases, helper-delegated settings, inferred base-method calls, deferred calls, later unproven model mutations, and transitive auto-includes on a different entity remain unproven and can still report; keep an explicit `Include`, project, or use a reviewed suppression for those shapes. - Widened `IEnumerable` aliases are diagnostic-only: the analyzer can still prove the DbSet root, but the fixer will not emit `Include` against a non-queryable source expression. - Current scope is intra-procedural and local-based (methods and constructors). Out of scope (quiet, not flagged): `await foreach`, arbitrary callbacks and delegate/method-group consumers, predicate/default-value element extractor overloads, custom extraction lookalikes, provider-specific temporal APIs, `Find*`, `Entry(...).Reference/Collection(...).Load()` recognition, and `IQueryable` parameters / repository-returned queries as roots. @@ -106,6 +106,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var autoIncluded = db.Orders.ToList(); foreach (var order in autoIncluded) Console.WriteLine(order.Customer.Name); +// Exact source-visible configuration classes are also recognised. +sealed class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + => builder.Navigation(o => o.Customer).AutoInclude(); +} +protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.ApplyConfiguration(new OrderConfiguration()); + var names = db.Orders.Select(o => o.Customer.Name).ToList(); // projection — out of scope var list = db.Orders.ToList(); diff --git a/src/LinqContraband/Analyzers/LoadingAndIncludes/LC045_MissingInclude/MissingIncludeAppliedConfiguration.cs b/src/LinqContraband/Analyzers/LoadingAndIncludes/LC045_MissingInclude/MissingIncludeAppliedConfiguration.cs new file mode 100644 index 0000000..236e278 --- /dev/null +++ b/src/LinqContraband/Analyzers/LoadingAndIncludes/LC045_MissingInclude/MissingIncludeAppliedConfiguration.cs @@ -0,0 +1,485 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using LinqContraband.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +namespace LinqContraband.Analyzers.LC045_MissingInclude; + +public sealed partial class MissingIncludeAnalyzer +{ + private static bool TryApplyConfigurationAutoIncludes( + IInvocationOperation invocation, + IParameterSymbol modelBuilderParameter, + Compilation compilation, + Dictionary> prefixesByEntity, + CancellationToken cancellationToken + ) + { + if ( + invocation.TargetMethod.Name != "ApplyConfiguration" + || invocation.TargetMethod.ContainingType.Name != "ModelBuilder" + || invocation.TargetMethod.ContainingType.ContainingNamespace.ToDisplayString() + != "Microsoft.EntityFrameworkCore" + || invocation.Instance?.UnwrapConversions() + is not IParameterReferenceOperation modelBuilderReference + || !SymbolEqualityComparer.Default.Equals( + modelBuilderReference.Parameter.OriginalDefinition, + modelBuilderParameter.OriginalDefinition + ) + || invocation.TargetMethod.TypeArguments.Length != 1 + || invocation.TargetMethod.TypeArguments[0] is not INamedTypeSymbol entityType + ) + { + return false; + } + + var configurationArgument = invocation.Arguments.FirstOrDefault(argument => + argument.Parameter?.Ordinal == 0 + ); + if ( + configurationArgument?.Value.UnwrapConversions() + is not IObjectCreationOperation configurationCreation + || configurationCreation.Arguments.Length != 0 + || configurationCreation.Type is not INamedTypeSymbol configurationType + || !TryGetExactConfigureMethod( + configurationType, + entityType, + out var configureMethod + ) + || configureMethod.IsAsync + || configureMethod.Parameters.Length != 1 + || configureMethod.Parameters[0].Type is not INamedTypeSymbol builderType + || !IsEfBuilderType(builderType, "EntityTypeBuilder") + ) + { + return false; + } + + var changes = new List(); + foreach (var syntaxReference in configureMethod.DeclaringSyntaxReferences) + { + cancellationToken.ThrowIfCancellationRequested(); + var syntax = syntaxReference.GetSyntax(cancellationToken); + var semanticModel = compilation.GetSemanticModel(syntax.SyntaxTree); + var configureOperation = syntax switch + { + MethodDeclarationSyntax { Body: not null } method => + semanticModel.GetOperation(method.Body, cancellationToken), + MethodDeclarationSyntax { ExpressionBody.Expression: var expression } => + semanticModel.GetOperation(expression, cancellationToken), + _ => null, + }; + if ( + configureOperation == null + || ContainsBuilderConsumingUserDefinedConversion( + configureOperation, + configureMethod.Parameters[0], + cancellationToken + ) + || + HasEscapedConfigurationBuilder( + syntax, + semanticModel, + configureMethod.Parameters[0], + cancellationToken + ) + ) + { + return false; + } + + foreach ( + var invocationSyntax in syntax.DescendantNodes().OfType() + ) + { + cancellationToken.ThrowIfCancellationRequested(); + if ( + semanticModel.GetOperation(invocationSyntax, cancellationToken) + is not IInvocationOperation configurationInvocation + ) + { + return false; + } + + if ( + IsNestedConfigurationExecutable(invocationSyntax, syntax) + && (ReferencesParameter( + configurationInvocation, + configureMethod.Parameters[0], + cancellationToken + ) + || UsesEfConfigurationBuilder(configurationInvocation)) + ) + { + return false; + } + + var isTopLevel = TryGetTopLevelConfigurationExecution( + configurationInvocation, + semanticModel, + out var isUnconditional + ); + if ( + TryGetAppliedConfigurationAutoInclude( + configurationInvocation, + configureMethod.Parameters[0], + entityType, + semanticModel, + out var includePath, + out var enabled + ) + ) + { + if (enabled == true) + { + if (isTopLevel && isUnconditional) + changes.Add(new AppliedAutoIncludeChange(includePath, true)); + } + else + { + changes.Add(new AppliedAutoIncludeChange(includePath, false)); + } + + continue; + } + + if ( + IsUnprovenAppliedConfigurationBoundary( + configurationInvocation, + configureMethod.Parameters[0], + cancellationToken + ) + ) + { + return false; + } + } + } + + if (!prefixesByEntity.TryGetValue(entityType, out var prefixes)) + { + prefixes = new HashSet(System.StringComparer.Ordinal); + prefixesByEntity[entityType] = prefixes; + } + + foreach (var change in changes) + { + if (change.Enabled) + { + AddPathPrefixes(change.Path, prefixes); + continue; + } + + var disabledPath = change.Path.Key; + prefixes.RemoveWhere(path => + path == disabledPath + || path.StartsWith(disabledPath + ".", System.StringComparison.Ordinal) + ); + } + + return true; + } + + private static bool HasEscapedConfigurationBuilder( + SyntaxNode configureSyntax, + SemanticModel semanticModel, + IParameterSymbol builderParameter, + CancellationToken cancellationToken + ) + { + foreach ( + var assignmentSyntax in configureSyntax + .DescendantNodes() + .OfType() + ) + { + cancellationToken.ThrowIfCancellationRequested(); + if ( + semanticModel.GetOperation(assignmentSyntax, cancellationToken) + is IAssignmentOperation assignment + ) + { + if ( + assignment.Target.UnwrapConversions() + is not (ILocalReferenceOperation or IDiscardOperation) + && (ReferencesParameter( + assignment.Value, + builderParameter, + cancellationToken + ) + || ReferencesParameter( + assignment.Target, + builderParameter, + cancellationToken + )) + ) + { + return true; + } + } + } + + foreach ( + var objectCreationSyntax in configureSyntax + .DescendantNodes() + .OfType() + ) + { + cancellationToken.ThrowIfCancellationRequested(); + if ( + semanticModel.GetOperation(objectCreationSyntax, cancellationToken) + is IObjectCreationOperation objectCreation + && objectCreation.Arguments.Any(argument => + ReferencesParameter( + argument.Value, + builderParameter, + cancellationToken + ) + || IsEfConfigurationBuilderType(argument.Value.Type) + ) + ) + { + return true; + } + } + + foreach ( + var elementAccessSyntax in configureSyntax + .DescendantNodes() + .OfType() + ) + { + cancellationToken.ThrowIfCancellationRequested(); + if ( + semanticModel.GetOperation(elementAccessSyntax, cancellationToken) + is IPropertyReferenceOperation propertyReference + && propertyReference.Property.IsIndexer + && ReferencesParameter( + propertyReference, + builderParameter, + cancellationToken + ) + ) + { + return true; + } + } + + return false; + } + + private static bool ContainsBuilderConsumingUserDefinedConversion( + IOperation operation, + IParameterSymbol builderParameter, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + if ( + operation is IConversionOperation { OperatorMethod: not null } conversion + && ReferencesParameter( + conversion.Operand, + builderParameter, + cancellationToken + ) + ) + { + return true; + } + + return operation.ChildOperations.Any(child => + ContainsBuilderConsumingUserDefinedConversion( + child, + builderParameter, + cancellationToken + ) + ); + } + + private static bool IsNestedConfigurationExecutable( + SyntaxNode invocationSyntax, + SyntaxNode configureSyntax + ) + { + return invocationSyntax + .Ancestors() + .TakeWhile(ancestor => ancestor != configureSyntax) + .Any(ancestor => + ancestor is LocalFunctionStatementSyntax or AnonymousFunctionExpressionSyntax + ); + } + + private static bool UsesEfConfigurationBuilder(IInvocationOperation invocation) + { + return IsEfConfigurationBuilderType(invocation.TargetMethod.ContainingType) + || IsEfConfigurationBuilderType(invocation.Instance?.Type) + || invocation.Arguments.Any(argument => + IsEfConfigurationBuilderType(argument.Value.Type) + ); + } + + private static bool IsEfConfigurationBuilderType(ITypeSymbol? type) + { + return type?.ContainingNamespace.ToDisplayString() + == "Microsoft.EntityFrameworkCore.Metadata.Builders"; + } + + private static bool TryGetExactConfigureMethod( + INamedTypeSymbol configurationType, + INamedTypeSymbol entityType, + out IMethodSymbol configureMethod + ) + { + configureMethod = null!; + foreach (var interfaceType in configurationType.AllInterfaces) + { + if ( + interfaceType.Name != "IEntityTypeConfiguration" + || interfaceType.ContainingNamespace.ToDisplayString() + != "Microsoft.EntityFrameworkCore" + || interfaceType.TypeArguments.Length != 1 + || !SymbolEqualityComparer.Default.Equals( + interfaceType.TypeArguments[0], + entityType + ) + ) + { + continue; + } + + var interfaceMethod = interfaceType + .GetMembers("Configure") + .OfType() + .SingleOrDefault(method => method.Parameters.Length == 1); + if ( + interfaceMethod == null + || configurationType.FindImplementationForInterfaceMember(interfaceMethod) + is not IMethodSymbol implementation + || implementation.DeclaringSyntaxReferences.Length == 0 + ) + { + return false; + } + + configureMethod = implementation; + return true; + } + + return false; + } + + private static bool TryGetAppliedConfigurationAutoInclude( + IInvocationOperation autoInclude, + IParameterSymbol builderParameter, + INamedTypeSymbol entityType, + SemanticModel semanticModel, + out IncludePath includePath, + out bool? enabled + ) + { + includePath = null!; + enabled = null; + if ( + IsChainedAutoIncludeReceiver(autoInclude) + || autoInclude.TargetMethod.Name != "AutoInclude" + || !IsEfBuilderType(autoInclude.TargetMethod.ContainingType, "NavigationBuilder") + || !TryGetAutoIncludeSetting(autoInclude, out enabled) + || !TryGetNavigationInvocation(autoInclude, out var navigationInvocation) + || navigationInvocation.TargetMethod.Name != "Navigation" + || !IsEfBuilderType( + navigationInvocation.TargetMethod.ContainingType, + "EntityTypeBuilder" + ) + || navigationInvocation.Instance?.UnwrapConversions() + is not IParameterReferenceOperation builderReference + || !SymbolEqualityComparer.Default.Equals( + builderReference.Parameter.OriginalDefinition, + builderParameter.OriginalDefinition + ) + || builderParameter.Type is not INamedTypeSymbol configuredBuilder + || configuredBuilder.TypeArguments.Length != 1 + || !SymbolEqualityComparer.Default.Equals( + configuredBuilder.TypeArguments[0], + entityType + ) + || !IncludePathParser.TryGetIncludePath( + navigationInvocation, + semanticModel, + null, + out includePath + ) + ) + { + return false; + } + + return true; + } + + private static bool IsUnprovenAppliedConfigurationBoundary( + IInvocationOperation invocation, + IParameterSymbol builderParameter, + CancellationToken cancellationToken + ) + { + if (IsChainedAutoIncludeReceiver(invocation)) + return false; + + if ( + !ReferencesParameter(invocation, builderParameter, cancellationToken) + || IsKnownEfBuilderOperation(invocation.TargetMethod, builderParameter) + ) + { + return false; + } + + return true; + } + + private static bool IsKnownEfBuilderOperation( + IMethodSymbol method, + IParameterSymbol builderParameter + ) + { + var containingType = method.ContainingType; + var containingNamespace = containingType.ContainingNamespace.ToDisplayString(); + var builderAssembly = builderParameter.Type.ContainingAssembly; + if ( + containingNamespace == "Microsoft.EntityFrameworkCore.Metadata.Builders" + && SymbolEqualityComparer.Default.Equals( + containingType.ContainingAssembly, + builderAssembly + ) + && containingType.Name == "EntityTypeBuilder" + && method.Name == "HasKey" + ) + { + return true; + } + + return method.Name == "ToTable" + && containingType.Name == "RelationalEntityTypeBuilderExtensions" + && containingNamespace == "Microsoft.EntityFrameworkCore" + && (containingType.ContainingAssembly.Name == "Microsoft.EntityFrameworkCore.Relational" + || SymbolEqualityComparer.Default.Equals( + containingType.ContainingAssembly, + builderAssembly + )); + } + + private readonly struct AppliedAutoIncludeChange + { + public AppliedAutoIncludeChange(IncludePath path, bool enabled) + { + Path = path; + Enabled = enabled; + } + + public IncludePath Path { get; } + + public bool Enabled { get; } + } +} diff --git a/src/LinqContraband/Analyzers/LoadingAndIncludes/LC045_MissingInclude/MissingIncludeAutoIncludeConfiguration.cs b/src/LinqContraband/Analyzers/LoadingAndIncludes/LC045_MissingInclude/MissingIncludeAutoIncludeConfiguration.cs index d8aceba..a5b9d40 100644 --- a/src/LinqContraband/Analyzers/LoadingAndIncludes/LC045_MissingInclude/MissingIncludeAutoIncludeConfiguration.cs +++ b/src/LinqContraband/Analyzers/LoadingAndIncludes/LC045_MissingInclude/MissingIncludeAutoIncludeConfiguration.cs @@ -85,6 +85,21 @@ is not IInvocationOperation configurationInvocation semanticModel, out var isUnconditional ); + if ( + isTopLevel + && isUnconditional + && TryApplyConfigurationAutoIncludes( + configurationInvocation, + method.Parameters[0], + compilation, + prefixesByEntity, + cancellationToken + ) + ) + { + continue; + } + if ( !TryGetDirectAutoInclude( configurationInvocation, @@ -97,7 +112,10 @@ out var enabled ) { if ( - isTopLevel + (isTopLevel + || CanNestedInvocationChangeAutoIncludes( + configurationInvocation + )) && IsUnprovenModelConfigurationBoundary( configurationInvocation, method.Parameters[0], @@ -187,7 +205,8 @@ out bool? enabled enabled = null; if ( - autoInclude.TargetMethod.Name != "AutoInclude" + IsChainedAutoIncludeReceiver(autoInclude) + || autoInclude.TargetMethod.Name != "AutoInclude" || !IsEfBuilderType(autoInclude.TargetMethod.ContainingType, "NavigationBuilder") || !TryGetAutoIncludeSetting(autoInclude, out enabled) || !TryGetNavigationInvocation(autoInclude, out var navigationInvocation) @@ -226,6 +245,21 @@ out includePath return true; } + private static bool IsChainedAutoIncludeReceiver(IInvocationOperation invocation) + { + IOperation current = invocation; + while (current.Parent is IConversionOperation or IParenthesizedOperation) + current = current.Parent; + + return current.Parent is IInvocationOperation outerInvocation + && outerInvocation.TargetMethod.Name == "AutoInclude" + && IsEfBuilderType( + outerInvocation.TargetMethod.ContainingType, + "NavigationBuilder" + ) + && object.ReferenceEquals(outerInvocation.Instance?.UnwrapConversions(), current); + } + private static bool TryGetNavigationInvocation( IInvocationOperation autoInclude, out IInvocationOperation navigationInvocation @@ -336,6 +370,46 @@ CancellationToken cancellationToken ); } + private static bool CanNestedInvocationChangeAutoIncludes( + IInvocationOperation invocation + ) + { + if ( + invocation.TargetMethod.Name + is "ApplyConfiguration" + or "ApplyConfigurationsFromAssembly" + or "OnModelCreating" + or "SetIsEagerLoaded" + ) + { + return true; + } + + var containingType = invocation.TargetMethod.ContainingType; + var containingNamespace = containingType.ContainingNamespace.ToDisplayString(); + if ( + containingType.Name == "ModelBuilder" + && containingNamespace == "Microsoft.EntityFrameworkCore" + && invocation.TargetMethod.Name == "Entity" + ) + { + return false; + } + + if ( + containingNamespace == "Microsoft.EntityFrameworkCore.Metadata.Builders" + && ((containingType.Name == "EntityTypeBuilder" + && invocation.TargetMethod.Name == "Navigation") + || (containingType.Name == "NavigationBuilder" + && invocation.TargetMethod.Name == "AutoInclude")) + ) + { + return false; + } + + return true; + } + private static bool ReferencesParameter( IOperation? operation, IParameterSymbol parameter, diff --git a/src/LinqContraband/Extensions/IncludePathParser.cs b/src/LinqContraband/Extensions/IncludePathParser.cs index 7a47a1c..b93a239 100644 --- a/src/LinqContraband/Extensions/IncludePathParser.cs +++ b/src/LinqContraband/Extensions/IncludePathParser.cs @@ -45,8 +45,8 @@ public IncludePath Append(IncludePath childPath) /// /// Parses EF Core Include/ThenInclude invocations (lambda, filtered-lambda, and constant-string -/// overloads) into values. Shared by LC006 (cartesian explosion) and -/// LC045 (missing include). +/// overloads) plus EntityTypeBuilder.Navigation paths into values. +/// Shared by LC006 (cartesian explosion) and LC045 (missing include). /// internal static partial class IncludePathParser { @@ -67,6 +67,14 @@ out IncludePath includePath return true; } + if ( + invocation.TargetMethod.Name == "Navigation" + && TryGetStringNavigationPath(invocation, out includePath) + ) + { + return true; + } + if (!TryGetLambdaExpression(invocation, out var lambdaExpression)) return false; @@ -139,6 +147,38 @@ out IncludePath includePath return true; } + private static bool TryGetStringNavigationPath( + IInvocationOperation invocation, + out IncludePath includePath + ) + { + includePath = new IncludePath(ImmutableArray.Empty); + var navigationArgument = invocation.Arguments.FirstOrDefault(argument => + argument.Parameter?.Ordinal == 0 + ); + if ( + navigationArgument?.Value.ConstantValue is not { HasValue: true, Value: string pathText } + || string.IsNullOrWhiteSpace(pathText) + || pathText.IndexOf('.') >= 0 + || invocation.Instance?.Type is not INamedTypeSymbol builderType + || builderType.TypeArguments.Length != 1 + ) + { + return false; + } + + var property = TryFindProperty(builderType.TypeArguments[0], pathText.Trim()); + if (property == null) + return false; + + includePath = new IncludePath( + ImmutableArray.Create( + new NavigationSegment(property.Name, IsCollection(property.Type)) + ) + ); + return true; + } + private static ITypeSymbol? GetSourceType(IInvocationOperation invocation) { if (invocation.Instance != null) diff --git a/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeAppliedConfigurationTests.cs b/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeAppliedConfigurationTests.cs new file mode 100644 index 0000000..3fc6d0e --- /dev/null +++ b/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeAppliedConfigurationTests.cs @@ -0,0 +1,871 @@ +using VerifyCS = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier; + +namespace LinqContraband.Tests.Analyzers.LC045_MissingInclude; + +public partial class MissingIncludeEdgeCasesTests +{ + [Fact] + public async Task TestInnocent_AppliedConfigurationAutoInclude_NoDiagnostic() + { + var test = CreateAppliedConfigurationTest( + "builder.Navigation(o => o.Customer).AutoInclude();", + "Console.WriteLine(order.Customer.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestInnocent_AppliedConfigurationStringAutoInclude_NoDiagnostic() + { + var test = CreateAppliedConfigurationTest( + "builder.Navigation(\"Customer\").AutoInclude();", + "Console.WriteLine(order.Customer.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestInnocent_UnrelatedEfBuilderCallsBeforeAutoInclude_NoDiagnostic() + { + var test = CreateAppliedConfigurationTest( + @"builder.HasKey(o => o.Id); + builder.Navigation(o => o.Customer).AutoInclude();", + "Console.WriteLine(order.Customer.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestCrime_IgnoreAfterAutoInclude_DoesNotSuppress() + { + var test = CreateAppliedConfigurationTest( + @"builder.Navigation(o => o.Customer).AutoInclude(); + builder.Ignore(o => o.Customer);", + "Console.WriteLine({|#0:order.Customer|}.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestInnocent_RelationalBuilderExtensionBeforeAutoInclude_NoDiagnostic() + { + var test = CreateAppliedConfigurationTest( + @"builder.ToTable(""Orders""); + builder.Navigation(o => o.Customer).AutoInclude();", + "Console.WriteLine(order.Customer.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationDifferentNavigation_DoesNotSuppress() + { + var test = CreateAppliedConfigurationTest( + "builder.Navigation(o => o.BillingCustomer).AutoInclude();", + "Console.WriteLine({|#0:order.Customer|}.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationDifferentStringNavigation_DoesNotSuppress() + { + var test = CreateAppliedConfigurationTest( + "builder.Navigation(\"BillingCustomer\").AutoInclude();", + "Console.WriteLine({|#0:order.Customer|}.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationRuntimeStringNavigation_DoesNotSuppress() + { + var test = CreateAppliedConfigurationTest( + @"var navigationName = DateTime.UtcNow.Ticks > 0 ? ""Customer"" : ""BillingCustomer""; + builder.Navigation(navigationName).AutoInclude();", + "Console.WriteLine({|#0:order.Customer|}.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationDisabledAutoInclude_DoesNotSuppress() + { + var test = CreateAppliedConfigurationTest( + "builder.Navigation(o => o.Customer).AutoInclude(false);", + "Console.WriteLine({|#0:order.Customer|}.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationRuntimeAutoInclude_DoesNotSuppress() + { + var test = CreateAppliedConfigurationTest( + "builder.Navigation(o => o.Customer).AutoInclude(DateTime.UtcNow.Ticks > 0);", + "Console.WriteLine({|#0:order.Customer|}.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationConditionalAutoInclude_DoesNotSuppress() + { + var test = CreateAppliedConfigurationTest( + @"if (DateTime.UtcNow.Ticks > 0) + { + builder.Navigation(o => o.Customer).AutoInclude(); + }", + "Console.WriteLine({|#0:order.Customer|}.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationHelperBoundary_DoesNotSuppress() + { + var test = + Usings + + @" +class OrderConfiguration : IEntityTypeConfiguration +{ + private static void ConfigureNavigation(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(false); + } + + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(); + if (DateTime.UtcNow.Ticks > 0) + { + ConfigureNavigation(builder); + } + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationLocalHelperExecutionOrder_DoesNotSuppress() + { + var test = CreateAppliedConfigurationTest( + @"void Disable() => builder.Navigation(o => o.Customer).AutoInclude(false); + builder.Navigation(o => o.Customer).AutoInclude(); + Disable();", + "Console.WriteLine({|#0:order.Customer|}.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationCapturedBuilderAlias_DoesNotSuppress() + { + var test = CreateAppliedConfigurationTest( + @"var alias = builder; + void Disable() => alias.Navigation(o => o.Customer).AutoInclude(false); + builder.Navigation(o => o.Customer).AutoInclude(); + Disable();", + "Console.WriteLine({|#0:order.Customer|}.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationBuilderIndexerSetter_DoesNotSuppress() + { + var test = + Usings + + @" +class DisableCustomerMutator +{ + public bool this[Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder] + { + set => builder.Navigation(o => o.Customer).AutoInclude(false); + } +} + +class OrderConfiguration : IEntityTypeConfiguration +{ + private readonly DisableCustomerMutator _mutator = new(); + + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(); + _mutator[builder] = true; + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationBuilderIndexerGetter_DoesNotSuppress() + { + var test = + Usings + + @" +class DisableCustomerMutator +{ + public bool this[Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder] + { + get + { + builder.Navigation(o => o.Customer).AutoInclude(false); + return true; + } + } +} + +class OrderConfiguration : IEntityTypeConfiguration +{ + private readonly DisableCustomerMutator _mutator = new(); + + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(); + var ignored = _mutator[builder]; + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationAsyncConfigure_DoesNotSuppress() + { + var test = + Usings + + @" +class OrderConfiguration : IEntityTypeConfiguration +{ + public async void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + await System.Threading.Tasks.Task.Yield(); + builder.Navigation(o => o.Customer).AutoInclude(); + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Theory] + [InlineData("DisableCustomerMutator ignored; ignored = builder;")] + [InlineData("DisableCustomerMutator ignored = builder;")] + [InlineData( + "DisableCustomerMutator ignored; ignored = true ? (DisableCustomerMutator)builder : new DisableCustomerMutator();" + )] + [InlineData( + "DisableCustomerMutator ignored = true ? (DisableCustomerMutator)builder : new DisableCustomerMutator();" + )] + [InlineData("if ((DisableCustomerMutator)builder != null) { }")] + public async Task TestCrime_AppliedConfigurationBuilderUserDefinedConversion_DoesNotSuppress( + string conversion + ) + { + var test = + Usings + + @" +class DisableCustomerMutator +{ + public static implicit operator DisableCustomerMutator( + Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(false); + return new DisableCustomerMutator(); + } +} + +class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(); + " + + conversion + + @" + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationConstructorConsumesModelBuilder_DoesNotSuppress() + { + var test = + Usings + + @" +class OrderConfiguration : IEntityTypeConfiguration +{ + private readonly Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder _saved; + + public OrderConfiguration( + Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder saved) + { + _saved = saved; + } + + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(); + _saved.Navigation(o => o.Customer).AutoInclude(false); + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration( + new OrderConfiguration(modelBuilder.Entity())); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationBuilderFieldEscape_DoesNotSuppress() + { + var test = + Usings + + @" +class OrderConfiguration : IEntityTypeConfiguration +{ + private Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder _saved; + + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + _saved = builder; + builder.Navigation(o => o.Customer).AutoInclude(); + Disable(); + } + + private void Disable() + { + _saved.Navigation(o => o.Customer).AutoInclude(false); + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationBuilderConstructorBoundary_DoesNotSuppress() + { + var test = + Usings + + @" +class DisableCustomer +{ + public DisableCustomer(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(false); + } +} + +class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(); + _ = new DisableCustomer(builder); + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_AppliedConfigurationBuilderAliasConstructorBoundary_DoesNotSuppress() + { + var test = + Usings + + @" +class DisableCustomer +{ + public DisableCustomer(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(false); + } +} + +class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + var alias = builder; + builder.Navigation(o => o.Customer).AutoInclude(); + _ = new DisableCustomer(alias); + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestInnocent_AppliedConfigurationUnrelatedConstructor_NoDiagnostic() + { + var test = CreateAppliedConfigurationTest( + @"_ = new object(); + builder.Navigation(o => o.Customer).AutoInclude();", + "Console.WriteLine(order.Customer.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestInnocent_AppliedConfigurationUnrelatedLocalHelper_NoDiagnostic() + { + var test = CreateAppliedConfigurationTest( + @"void Log() => Console.WriteLine(""Configuring Order""); + Log(); + builder.Navigation(o => o.Customer).AutoInclude();", + "Console.WriteLine(order.Customer.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestCrime_SourceDefinedEfNamespaceHelper_DoesNotSuppress() + { + var test = + Usings + + @" +namespace Microsoft.EntityFrameworkCore +{ + public static class CustomBuilderExtensions + { + public static Metadata.Builders.EntityTypeBuilder ConfigureAutoInclude( + this Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(false); + return builder; + } + } +} + +class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(); + builder.ConfigureAutoInclude(); + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_LaterAppliedConfigurationDisableWins() + { + var test = + Usings + + @" +class EnableOrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(); + } +} + +class DisableOrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(false); + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new EnableOrderConfiguration()); + modelBuilder.ApplyConfiguration(new DisableOrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_ConditionalAppliedConfigurationDisableInvalidatesEarlierEnable() + { + var test = + Usings + + @" +class DisableOrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + builder.Navigation(o => o.Customer).AutoInclude(false); + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Navigation(o => o.Customer).AutoInclude(); + if (DateTime.UtcNow.Ticks > 0) + { + modelBuilder.ApplyConfiguration(new DisableOrderConfiguration()); + } + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestInnocent_LaterFluentAutoIncludeEnableWins() + { + var test = CreateAppliedConfigurationTest( + "builder.Navigation(o => o.Customer).AutoInclude(false).AutoInclude();", + "Console.WriteLine(order.Customer.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestCrime_LaterFluentAutoIncludeDisableWins() + { + var test = CreateAppliedConfigurationTest( + "builder.Navigation(o => o.Customer).AutoInclude().AutoInclude(false);", + "Console.WriteLine({|#0:order.Customer|}.Name);" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_IgnoreAutoIncludesOverridesAppliedConfiguration() + { + var test = CreateAppliedConfigurationTest( + "builder.Navigation(o => o.Customer).AutoInclude();", + "Console.WriteLine({|#0:order.Customer|}.Name);", + "db.Orders.IgnoreAutoIncludes().ToList()" + ); + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + private static string CreateAppliedConfigurationTest( + string configurationBody, + string access, + string query = "db.Orders.ToList()" + ) + { + return Usings + + @" +class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder) + { + " + + configurationBody + + @" + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = " + + query + + @"; + foreach (var order in orders) + { + " + + access + + @" + } + } +} +" + + MockNamespace; + } +} diff --git a/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeAutoIncludeTests.cs b/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeAutoIncludeTests.cs index 05a11c1..08a8d50 100644 --- a/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeAutoIncludeTests.cs +++ b/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeAutoIncludeTests.cs @@ -695,4 +695,125 @@ void Main() await VerifyCS.VerifyAnalyzerAsync(test); } + + [Fact] + public async Task TestInnocent_FluentAutoIncludeEnableAfterDisable_NoDiagnostic() + { + var test = + Usings + + @" +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Navigation(o => o.Customer).AutoInclude(false).AutoInclude(); + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine(order.Customer.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Fact] + public async Task TestCrime_ConditionalHelperDisableInvalidatesEarlierEnable() + { + var test = + Usings + + @" +class AutoIncludeDbContext : MyDbContext +{ + private static void Configure(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Navigation(o => o.Customer).AutoInclude(false); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Navigation(o => o.Customer).AutoInclude(); + if (DateTime.UtcNow.Ticks > 0) + { + Configure(modelBuilder); + } + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + + [Fact] + public async Task TestCrime_ConditionalSourceDefinedEfNamespaceHelperInvalidatesEarlierEnable() + { + var test = + Usings + + @" +namespace Microsoft.EntityFrameworkCore +{ + public static class CustomModelBuilderExtensions + { + public static void ConfigureAutoInclude(this ModelBuilder modelBuilder) + { + modelBuilder.Entity().Navigation(o => o.Customer).AutoInclude(false); + } + } +} + +class AutoIncludeDbContext : MyDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Navigation(o => o.Customer).AutoInclude(); + if (DateTime.UtcNow.Ticks > 0) + { + modelBuilder.ConfigureAutoInclude(); + } + } +} + +class Program +{ + void Main() + { + var db = new AutoIncludeDbContext(); + var orders = db.Orders.ToList(); + foreach (var order in orders) + { + Console.WriteLine({|#0:order.Customer|}.Name); + } + } +} +" + + MockNamespace; + + await VerifyCS.VerifyAnalyzerAsync(test, Diagnostic(0, "Customer", "Order")); + } + } diff --git a/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeEdgeCasesTests.cs b/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeEdgeCasesTests.cs index bc96915..5b6dbb2 100644 --- a/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeEdgeCasesTests.cs +++ b/tests/LinqContraband.Tests/Analyzers/LC045_MissingInclude/MissingIncludeEdgeCasesTests.cs @@ -51,17 +51,36 @@ public void Dispose() { } public class ModelBuilder { public Metadata.Builders.EntityTypeBuilder Entity() where TEntity : class => null; + + public void ApplyConfiguration(IEntityTypeConfiguration configuration) + where TEntity : class { } + } + + public interface IEntityTypeConfiguration where TEntity : class + { + void Configure(Metadata.Builders.EntityTypeBuilder builder); } namespace Metadata.Builders { public class EntityTypeBuilder where TEntity : class { + public EntityTypeBuilder HasKey( + System.Linq.Expressions.Expression> keyExpression) => this; + + public EntityTypeBuilder Ignore( + System.Linq.Expressions.Expression> propertyExpression) => this; + public NavigationBuilder Navigation( System.Linq.Expressions.Expression> navigationExpression) => null; + + public NavigationBuilder Navigation(string navigationName) => null; } - public class NavigationBuilder { } + public class NavigationBuilder + { + public virtual NavigationBuilder AutoInclude(bool autoInclude = true) => this; + } public class NavigationBuilder : NavigationBuilder where TSource : class @@ -72,6 +91,13 @@ public class NavigationBuilder : NavigationBuilder public class EntityEntry where T : class { } + public static class RelationalEntityTypeBuilderExtensions + { + public static Metadata.Builders.EntityTypeBuilder ToTable( + this Metadata.Builders.EntityTypeBuilder builder, + string name) where TEntity : class => builder; + } + public class DbSet : IQueryable where T : class { public Type ElementType => typeof(T); diff --git a/tests/LinqContraband.Tests/Architecture/AnalyzerModularizationTests.cs b/tests/LinqContraband.Tests/Architecture/AnalyzerModularizationTests.cs index 65cb029..37b22d5 100644 --- a/tests/LinqContraband.Tests/Architecture/AnalyzerModularizationTests.cs +++ b/tests/LinqContraband.Tests/Architecture/AnalyzerModularizationTests.cs @@ -523,6 +523,7 @@ public void LC045_OriginAwareFlowResponsibilities_LiveInDedicatedPartials() var flowEventsPath = Path.Combine(analyzerDir, "MissingIncludeOriginFlowEvents.cs"); var flowStatePath = Path.Combine(analyzerDir, "MissingIncludeOriginFlowState.cs"); var autoIncludePath = Path.Combine(analyzerDir, "MissingIncludeAutoIncludeConfiguration.cs"); + var appliedConfigurationPath = Path.Combine(analyzerDir, "MissingIncludeAppliedConfiguration.cs"); Assert.True(File.Exists(flowAnalysisPath), "LC045 origin-aware control-flow analysis should live in a focused partial file."); Assert.True(File.Exists(flowBindingsPath), "LC045 origin and alias binding discovery should live in a focused partial file."); @@ -530,6 +531,7 @@ public void LC045_OriginAwareFlowResponsibilities_LiveInDedicatedPartials() Assert.True(File.Exists(flowEventsPath), "LC045 origin-flow event collection should live in a focused partial file."); Assert.True(File.Exists(flowStatePath), "LC045 origin-flow state and event models should live in a focused partial file."); Assert.True(File.Exists(autoIncludePath), "LC045 model-level AutoInclude proof should live in a focused partial file."); + Assert.True(File.Exists(appliedConfigurationPath), "LC045 applied AutoInclude configuration proof should live in a focused partial file."); var usageScanSource = File.ReadAllText(usageScanPath); Assert.DoesNotContain("private static bool TryGetFlowGraph", usageScanSource); @@ -549,6 +551,22 @@ public void LC045_OriginAwareFlowResponsibilities_LiveInDedicatedPartials() Assert.Contains("private static void AddModelAutoIncludePrefixes", autoIncludeSource); Assert.Contains("private static bool TryGetDirectAutoInclude", autoIncludeSource); + var appliedConfigurationSource = File.ReadAllText(appliedConfigurationPath); + Assert.Contains("private static bool TryApplyConfigurationAutoIncludes", appliedConfigurationSource); + Assert.Contains("private static bool TryGetAppliedConfigurationAutoInclude", appliedConfigurationSource); + + var appliedConfigurationTestsPath = Path.Combine( + _repoRoot, + "tests", + "LinqContraband.Tests", + "Analyzers", + "LC045_MissingInclude", + "MissingIncludeAppliedConfigurationTests.cs"); + Assert.True(File.Exists(appliedConfigurationTestsPath), "LC045 applied AutoInclude configuration tests should live in a focused partial file."); + var appliedConfigurationTestsSource = File.ReadAllText(appliedConfigurationTestsPath); + Assert.Contains("TestInnocent_AppliedConfigurationAutoInclude_NoDiagnostic", appliedConfigurationTestsSource); + Assert.Contains("TestCrime_AppliedConfigurationHelperBoundary_DoesNotSuppress", appliedConfigurationTestsSource); + var flowAnalysisSource = File.ReadAllText(flowAnalysisPath); Assert.Contains("private static bool TryCollectOriginAwareNavigationAccesses", flowAnalysisSource); Assert.Contains("private static bool TryGetFlowGraph", flowAnalysisSource);