Skip to content

Commit f11c5e9

Browse files
authored
Add EF1004 analyzer for ToAsyncEnumerable on IQueryable (#38218)
Fixes #37670
1 parent 7430f7a commit f11c5e9

7 files changed

Lines changed: 493 additions & 0 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
### New Rules
2+
Rule ID | Category | Severity | Notes
3+
--------|----------|----------|-------
4+
EF1004 | Usage | Warning | ToAsyncEnumerableOnQueryableDiagnosticAnalyzer

src/EFCore.Analyzers/Properties/AnalyzerStrings.Designer.cs

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/EFCore.Analyzers/Properties/AnalyzerStrings.resx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,13 @@
4242
<data name="StringConcatenationUsageInRawQueriesMessageFormat" xml:space="preserve">
4343
<value>Method '{0}' inserts concatenated strings directly into the SQL, without any protection against SQL injection. Consider using '{1}' instead, which protects against SQL injection, or make sure that the value is sanitized and suppress the warning.</value>
4444
</data>
45+
<data name="ToAsyncEnumerableOnQueryableTitle" xml:space="preserve">
46+
<value>Avoid using 'ToAsyncEnumerable' on an 'IQueryable'.</value>
47+
</data>
48+
<data name="ToAsyncEnumerableOnQueryableMessageFormat" xml:space="preserve">
49+
<value>Calling 'ToAsyncEnumerable' on an 'IQueryable' iterates the source synchronously. Use 'AsAsyncEnumerable' instead to iterate the database query asynchronously, or suppress this warning if the 'IQueryable' is not an EF Core query.</value>
50+
</data>
51+
<data name="ToAsyncEnumerableOnQueryableCodeActionTitle" xml:space="preserve">
52+
<value>Use 'AsAsyncEnumerable' to iterate the query asynchronously</value>
53+
</data>
4554
</root>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Immutable;
5+
using System.Composition;
6+
using Microsoft.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.CodeActions;
8+
using Microsoft.CodeAnalysis.CodeFixes;
9+
using Microsoft.CodeAnalysis.CSharp;
10+
using Microsoft.CodeAnalysis.CSharp.Syntax;
11+
12+
namespace Microsoft.EntityFrameworkCore;
13+
14+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ToAsyncEnumerableOnQueryableCodeFixProvider)), Shared]
15+
public sealed class ToAsyncEnumerableOnQueryableCodeFixProvider : CodeFixProvider
16+
{
17+
public override ImmutableArray<string> FixableDiagnosticIds
18+
=> [EFDiagnostics.ToAsyncEnumerableOnQueryable];
19+
20+
public override FixAllProvider GetFixAllProvider()
21+
=> WellKnownFixAllProviders.BatchFixer;
22+
23+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
24+
{
25+
var document = context.Document;
26+
var cancellationToken = context.CancellationToken;
27+
28+
// The analyzer reports a single diagnostic per location.
29+
var diagnostic = context.Diagnostics.First();
30+
31+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
32+
if (root is null)
33+
{
34+
return;
35+
}
36+
37+
if (root.FindNode(diagnostic.Location.SourceSpan) is not SimpleNameSyntax simpleName)
38+
{
39+
Debug.Fail("Analyzer reported diagnostic not on a SimpleNameSyntax. This should never happen");
40+
return;
41+
}
42+
43+
// Skip the static-call form `AsyncEnumerable.ToAsyncEnumerable<T>(q)`. Fixing it would require a
44+
// structural rewrite to a different containing type (EntityFrameworkQueryableExtensions) or to
45+
// the instance form, which is beyond a pure name swap. The analyzer still warns; the user can
46+
// refactor manually.
47+
if (simpleName.Parent is MemberAccessExpressionSyntax
48+
{
49+
Expression: IdentifierNameSyntax { Identifier.ValueText: "AsyncEnumerable" }
50+
})
51+
{
52+
return;
53+
}
54+
55+
context.RegisterCodeFix(
56+
CodeAction.Create(
57+
AnalyzerStrings.ToAsyncEnumerableOnQueryableCodeActionTitle,
58+
_ => Task.FromResult(document.WithSyntaxRoot(root.ReplaceNode(simpleName, GetReplacementName(simpleName)))),
59+
nameof(ToAsyncEnumerableOnQueryableCodeFixProvider)),
60+
diagnostic);
61+
}
62+
63+
private static SimpleNameSyntax GetReplacementName(SimpleNameSyntax oldName)
64+
{
65+
// Preserve trivia (comments/whitespace) and any generic type arguments — only the identifier changes.
66+
var newToken = SyntaxFactory.Identifier("AsAsyncEnumerable").WithTriviaFrom(oldName.Identifier);
67+
return oldName.WithIdentifier(newToken);
68+
}
69+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Immutable;
5+
using Microsoft.CodeAnalysis;
6+
using Microsoft.CodeAnalysis.CSharp.Syntax;
7+
using Microsoft.CodeAnalysis.Diagnostics;
8+
using Microsoft.CodeAnalysis.Operations;
9+
10+
namespace Microsoft.EntityFrameworkCore;
11+
12+
/// <summary>
13+
/// Reports calls to <c>System.Linq.AsyncEnumerable.ToAsyncEnumerable&lt;TSource&gt;(IEnumerable&lt;TSource&gt;)</c>
14+
/// whose source is an <c>IQueryable&lt;T&gt;</c>. The conversion forces the queryable to be enumerated synchronously,
15+
/// defeating the purpose of using <c>await foreach</c>. EF Core's <c>AsAsyncEnumerable&lt;T&gt;()</c> is the
16+
/// intended async-friendly alternative.
17+
/// </summary>
18+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
19+
public sealed class ToAsyncEnumerableOnQueryableDiagnosticAnalyzer : DiagnosticAnalyzer
20+
{
21+
private static readonly DiagnosticDescriptor Descriptor
22+
= new(
23+
EFDiagnostics.ToAsyncEnumerableOnQueryable,
24+
title: AnalyzerStrings.ToAsyncEnumerableOnQueryableTitle,
25+
messageFormat: AnalyzerStrings.ToAsyncEnumerableOnQueryableMessageFormat,
26+
category: "Usage",
27+
defaultSeverity: DiagnosticSeverity.Warning,
28+
isEnabledByDefault: true);
29+
30+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
31+
=> [Descriptor];
32+
33+
public override void Initialize(AnalysisContext context)
34+
{
35+
context.EnableConcurrentExecution();
36+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
37+
38+
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
39+
}
40+
41+
private static void AnalyzeInvocation(OperationAnalysisContext context)
42+
{
43+
var invocation = (IInvocationOperation)context.Operation;
44+
var targetMethod = invocation.TargetMethod;
45+
46+
if (targetMethod.Name != "ToAsyncEnumerable")
47+
{
48+
return;
49+
}
50+
51+
// Only flag the System.Linq.AsyncEnumerable.ToAsyncEnumerable<TSource>(IEnumerable<TSource>) overload.
52+
// Other libraries may define methods with the same name (e.g. on Channel<T>, IObservable<T>) — we don't
53+
// want to false-positive on those.
54+
var containingType = targetMethod.ContainingType;
55+
if (containingType is null
56+
|| containingType.Name != "AsyncEnumerable"
57+
|| containingType.ContainingNamespace?.ToDisplayString() != "System.Linq")
58+
{
59+
return;
60+
}
61+
62+
// The source is exposed as Arguments[0].Value for an extension method invocation. Peel any
63+
// chained implicit conversions to recover the user-written expression's type — a single C#
64+
// expression can produce stacked IConversionOperation nodes in the Roslyn tree. An explicit
65+
// cast to IEnumerable<T> (e.g. ((IEnumerable<T>)q).ToAsyncEnumerable()) shows up as
66+
// IsImplicit: false, so the loop stops there and the underlying IQueryable type is hidden —
67+
// a deliberate opt-out. Mirrors the WalkDownConversion(predicate) helper used across
68+
// dotnet/roslyn-analyzers.
69+
if (invocation.Arguments.Length == 0)
70+
{
71+
return;
72+
}
73+
74+
var sourceOperation = invocation.Arguments[0].Value;
75+
while (sourceOperation is IConversionOperation { IsImplicit: true } conversion)
76+
{
77+
sourceOperation = conversion.Operand;
78+
}
79+
80+
if (sourceOperation.Type is not { } sourceType || !ImplementsGenericIQueryable(sourceType))
81+
{
82+
return;
83+
}
84+
85+
context.ReportDiagnostic(Diagnostic.Create(Descriptor, GetInvocationLocation(invocation)));
86+
}
87+
88+
private static bool ImplementsGenericIQueryable(ITypeSymbol type)
89+
{
90+
if (IsGenericIQueryable(type))
91+
{
92+
return true;
93+
}
94+
95+
foreach (var iface in type.AllInterfaces)
96+
{
97+
if (IsGenericIQueryable(iface))
98+
{
99+
return true;
100+
}
101+
}
102+
103+
return false;
104+
105+
static bool IsGenericIQueryable(ITypeSymbol candidate)
106+
=> candidate is INamedTypeSymbol { Name: "IQueryable", TypeArguments.Length: 1 } named
107+
&& named.ContainingNamespace?.ToDisplayString() == "System.Linq";
108+
}
109+
110+
private static Location GetInvocationLocation(IInvocationOperation invocation)
111+
{
112+
if (invocation.Syntax is not InvocationExpressionSyntax invocationExpression)
113+
{
114+
return invocation.Syntax.GetLocation();
115+
}
116+
117+
var targetNode = invocationExpression.Expression;
118+
119+
while (targetNode is MemberAccessExpressionSyntax memberAccess)
120+
{
121+
targetNode = memberAccess.Name;
122+
}
123+
124+
// Generic name case (e.g. `AsyncEnumerable.ToAsyncEnumerable<T>(q)`): point at just the identifier.
125+
if (targetNode is GenericNameSyntax genericName)
126+
{
127+
return genericName.Identifier.GetLocation();
128+
}
129+
130+
return targetNode.GetLocation();
131+
}
132+
}

src/Shared/EFDiagnostics.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ internal static class EFDiagnostics
1111
internal const string InternalUsage = "EF1001";
1212
internal const string InterpolatedStringUsageInRawQueries = "EF1002";
1313
internal const string StringConcatenationUsageInRawQueries = "EF1003";
14+
internal const string ToAsyncEnumerableOnQueryable = "EF1004";
1415
internal const string SuppressUninitializedDbSetRule = "EFSPR1001";
1516

1617
// Diagnostics for [Obsolete]

0 commit comments

Comments
 (0)