Skip to content

Commit 4853835

Browse files
feat: enable IQueryable LINQ support by default (#291)
* feat: enable queryable support by default * Update CHANGELOG.md
1 parent f2eba42 commit 4853835

12 files changed

+119
-26
lines changed

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010

1111
### Added
1212

13-
- PR [#PR_NUMBER](https://github.com/marinasundstrom/CheckedExceptions/pull/PR_NUMBER) Handle LINQ methods on `AsyncEnumerable`
13+
- PR [#290](https://github.com/marinasundstrom/CheckedExceptions/pull/290) Handle LINQ methods on `AsyncEnumerable`
14+
- PR [#291](https://github.com/marinasundstrom/CheckedExceptions/pull/291) LINQ support for `IQueryable` enabled by default with option to disable via `disableLinqQueryableSupport`
1415

1516
### Changed
1617

17-
- PR [#PR_NUMBER](https://github.com/marinasundstrom/CheckedExceptions/pull/PR_NUMBER) Trim NuGet package README and document maintenance guidelines
18+
- PR [#290](https://github.com/marinasundstrom/CheckedExceptions/pull/290) Trim NuGet package README and document maintenance guidelines
1819

1920
### Fixed
2021

CheckedExceptions.Tests/CheckedExceptionsAnalyzerTests.Linq.cs

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,8 @@ await Verifier.VerifyAnalyzerAsync(test, setup: o =>
395395
}
396396

397397
[Fact]
398-
public async Task ReturnEnumerable()
399-
{
398+
public async Task ReturnEnumerable()
399+
{
400400
var test = /* lang=c#-test */ """
401401
#nullable enable
402402
using System;
@@ -425,9 +425,56 @@ IEnumerable<string> Get()
425425
var expected4 = Verifier.UnhandledExceptionEnumerableBoundary("IEnumerable<string>", "OverflowException")
426426
.WithSpan(10, 12, 10, 17);
427427

428-
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
429-
{
430-
o.ExpectedDiagnostics.AddRange(expected, expected2, expected3, expected4);
431-
}, executable: true);
432-
}
428+
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
429+
{
430+
o.ExpectedDiagnostics.AddRange(expected, expected2, expected3, expected4);
431+
}, executable: true);
432+
}
433+
434+
[Fact]
435+
public async Task QueryableOperator_EnabledByDefault()
436+
{
437+
var test = /* lang=c#-test */ """
438+
#nullable enable
439+
using System;
440+
using System.Collections.Generic;
441+
using System.Linq;
442+
443+
IQueryable<int> items = new List<int>().AsQueryable();
444+
var query = items.Where(x => true);
445+
var r = Queryable.First<int>(query);
446+
""";
447+
448+
var expected = Verifier.UnhandledException("InvalidOperationException")
449+
.WithSpan(8, 19, 8, 36);
450+
451+
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
452+
{
453+
o.ExpectedDiagnostics.Add(expected);
454+
}, executable: true);
455+
}
456+
457+
[Fact]
458+
public async Task QueryableOperator_Disabled()
459+
{
460+
var test = /* lang=c#-test */ """
461+
#nullable enable
462+
using System;
463+
using System.Collections.Generic;
464+
using System.Linq;
465+
466+
IQueryable<int> items = new List<int>().AsQueryable();
467+
var query = items.Where(x => true);
468+
var r = Queryable.First<int>(query);
469+
""";
470+
471+
await Verifier.VerifyAnalyzerAsync(test, setup: o =>
472+
{
473+
o.TestState.AdditionalFiles.Add(("CheckedExceptions.settings.json", """
474+
{
475+
"disableLinqQueryableSupport": true
476+
}
477+
"""));
478+
}, executable: true);
479+
}
433480
}

CheckedExceptions/AnalyzerSettings.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ public partial class AnalyzerSettings
2828
[JsonIgnore]
2929
internal bool IsLinqEnumerableBoundaryWarningsEnabled => !DisableLinqEnumerableBoundaryWarnings;
3030

31+
[JsonPropertyName("disableLinqQueryableSupport")]
32+
public bool DisableLinqQueryableSupport { get; set; } = false;
33+
34+
[JsonIgnore]
35+
internal bool IsLinqQueryableSupportEnabled => !DisableLinqQueryableSupport;
36+
3137
[JsonPropertyName("disableControlFlowAnalysis")]
3238
public bool DisableControlFlowAnalysis { get; set; } = false;
3339

CheckedExceptions/CheckedExceptionsAnalyzer.Analysis.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,9 @@ private static void AnalyzeFunctionAttributes(SyntaxNode node, IEnumerable<Attri
535535

536536
private static void AnalyzeLinqOperation(SyntaxNodeAnalysisContext context, IMethodSymbol methodSymbol, HashSet<INamedTypeSymbol> exceptionTypes, InvocationExpressionSyntax invocation)
537537
{
538-
if (IsLinqExtension(methodSymbol))
538+
var settings = GetAnalyzerSettings(context.Options);
539+
540+
if (IsLinqExtension(methodSymbol, settings))
539541
{
540542
var name = methodSymbol.Name;
541543

@@ -548,8 +550,6 @@ private static void AnalyzeLinqOperation(SyntaxNodeAnalysisContext context, IMet
548550
}
549551
}
550552

551-
var settings = GetAnalyzerSettings(context.Options);
552-
553553
CollectLinqExceptions(invocation, exceptionTypes, context.Compilation, context.SemanticModel, settings, context.CancellationToken);
554554
}
555555
}

CheckedExceptions/CheckedExceptionsAnalyzer.Linq.cs

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ private static void CollectLinqExceptions(
2121
{
2222
if (semanticModel.GetOperation(invocationSyntax, ct) is not IInvocationOperation termOp)
2323
return;
24-
if (!IsLinqExtension(termOp.TargetMethod))
24+
if (!IsLinqExtension(termOp.TargetMethod, settings))
2525
return;
2626

2727
var name = GetLinqName(termOp.TargetMethod);
@@ -33,7 +33,7 @@ private static void CollectLinqExceptions(
3333
// defer handling until the outermost call is analyzed.
3434
if (invocationSyntax.Parent is MemberAccessExpressionSyntax { Parent: InvocationExpressionSyntax outer } &&
3535
semanticModel.GetOperation(outer, ct) is IInvocationOperation outerOp &&
36-
IsLinqExtension(outerOp.TargetMethod))
36+
IsLinqExtension(outerOp.TargetMethod, settings))
3737
{
3838
return;
3939
}
@@ -108,7 +108,7 @@ private static string GetLinqName(IMethodSymbol method)
108108
return name;
109109
}
110110

111-
private static bool IsLinqExtension(IMethodSymbol method)
111+
private static bool IsLinqExtension(IMethodSymbol method, AnalyzerSettings settings)
112112
{
113113
if (method is null || !method.IsExtensionMethod) return false;
114114

@@ -119,8 +119,26 @@ private static bool IsLinqExtension(IMethodSymbol method)
119119
if (ns != "System.Linq") return false;
120120

121121
var name = containingType.Name;
122-
return name.EndsWith("Enumerable", StringComparison.Ordinal)
123-
|| name.EndsWith("Queryable", StringComparison.Ordinal);
122+
if (name.EndsWith("Enumerable", StringComparison.Ordinal))
123+
return true;
124+
125+
if (name.EndsWith("Queryable", StringComparison.Ordinal))
126+
return settings.IsLinqQueryableSupportEnabled;
127+
128+
return false;
129+
}
130+
131+
private static bool IsQueryableExtension(IMethodSymbol method)
132+
{
133+
if (method is null || !method.IsExtensionMethod) return false;
134+
135+
var containingType = method.ContainingType;
136+
if (containingType is null) return false;
137+
138+
var ns = containingType.ContainingNamespace?.ToDisplayString();
139+
if (ns != "System.Linq") return false;
140+
141+
return containingType.Name.EndsWith("Queryable", StringComparison.Ordinal);
124142
}
125143

126144
private static IOperation? GetLinqSourceOperation(IInvocationOperation op)
@@ -144,7 +162,7 @@ private static void CollectDeferredChainExceptions(
144162
{
145163
switch (current)
146164
{
147-
case IInvocationOperation inv when IsLinqExtension(inv.TargetMethod):
165+
case IInvocationOperation inv when IsLinqExtension(inv.TargetMethod, settings):
148166
var name = GetLinqName(inv.TargetMethod);
149167

150168
if (LinqKnowledge.DeferredOps.Contains(name))
@@ -316,7 +334,7 @@ private void CollectEnumerationExceptions(
316334
}
317335

318336
if (inner is IInvocationOperation inv &&
319-
IsLinqExtension(inv.TargetMethod) &&
337+
IsLinqExtension(inv.TargetMethod, settings) &&
320338
LinqKnowledge.TerminalOps.Contains(GetLinqName(inv.TargetMethod)))
321339
{
322340
return;
@@ -340,7 +358,7 @@ private static void CollectDeferredChainExceptions_ForEnumeration(
340358
{
341359
switch (current)
342360
{
343-
case IInvocationOperation inv when IsLinqExtension(inv.TargetMethod):
361+
case IInvocationOperation inv when IsLinqExtension(inv.TargetMethod, settings):
344362
{
345363
var name = GetLinqName(inv.TargetMethod);
346364

CheckedExceptions/CheckedExceptionsAnalyzer.Members.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ private static void AnalyzeMemberExceptions(SyntaxNodeAnalysisContext context, S
1515
if (methodSymbol is null)
1616
return;
1717

18+
if (!settings.IsLinqQueryableSupportEnabled && IsQueryableExtension(methodSymbol))
19+
return;
20+
1821
var exceptionTypes = new HashSet<INamedTypeSymbol>(
1922
GetExceptionTypes(methodSymbol), SymbolEqualityComparer.Default);
2023

CheckedExceptions/CheckedExceptionsAnalyzer.Throw.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ private static void AnalyzeExceptionThrowingNode(
7676
if (!isHandled && !isDeclared)
7777
{
7878
if (settings.IsLinqImplicitlyDeclaredExceptionsEnabled
79-
&& IsInsideLinqLambda(node, semanticModel, out _))
79+
&& IsInsideLinqLambda(node, semanticModel, settings, out _))
8080
{
8181
// Implicitly declared exceptions in LINQ expressions
8282

@@ -116,6 +116,7 @@ private static void AnalyzeExceptionThrowingNode(
116116
private static bool IsInsideLinqLambda(
117117
SyntaxNode node,
118118
SemanticModel semanticModel,
119+
AnalyzerSettings settings,
119120
out IInvocationOperation? linqInvocation,
120121
CancellationToken ct = default)
121122
{
@@ -153,7 +154,7 @@ private static bool IsInsideLinqLambda(
153154
if (inv is null) return false;
154155

155156
// finally: is it a LINQ query operator?
156-
if (!IsLinqExtension(inv.TargetMethod)) return false;
157+
if (!IsLinqExtension(inv.TargetMethod, settings)) return false;
157158

158159
linqInvocation = inv;
159160
return true;

CheckedExceptions/CheckedExceptionsAnalyzer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ private void AnalyzeArgument(SyntaxNodeAnalysisContext context)
290290
// the invocation analysis will handle diagnostics. Skip boundary reporting.
291291
if (argumentSyntax.Expression is InvocationExpressionSyntax invSyntax &&
292292
semanticModel.GetOperation(invSyntax) is IInvocationOperation invOp &&
293-
IsLinqExtension(invOp.TargetMethod) &&
293+
IsLinqExtension(invOp.TargetMethod, settings) &&
294294
LinqKnowledge.TerminalOps.Contains(invOp.TargetMethod.Name))
295295
{
296296
return;

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ This is due to a **technical limitation**: the XML documentation files for .NET
307307

308308
**Answer:**
309309

310-
There is support for LINQ query operators on `IEnumerable<T>` and asynchronous operators like `FirstAsync` on `IAsyncEnumerable<T>` (via [System.Linq.Async](https://www.nuget.org/packages/System.Linq.Async)).
310+
There is support for LINQ query operators on `IEnumerable<T>` and asynchronous operators like `FirstAsync` on `IAsyncEnumerable<T>` (via [System.Linq.Async](https://www.nuget.org/packages/System.Linq.Async)). Support for `IQueryable<T>` is enabled by default and can be disabled via the `disableLinqQueryableSupport` setting.
311311

312312
```csharp
313313
List<string> values = [ "10", "20", "abc", "30" ];

docs/analyzer-specification.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ This is treated as valid when there is only a `get` accessor.
400400
401401
### LINQ queries
402402

403-
The analyzer recognizes LINQ query operators whose extensions live in the `System.Linq` namespace and whose containing type name ends with `Enumerable` or `Queryable`, including `AsyncEnumerable` from the [`System.Linq.Async`](https://www.nuget.org/packages/System.Linq.Async) package.
403+
The analyzer recognizes LINQ query operators whose extensions live in the `System.Linq` namespace and whose containing type name ends with `Enumerable`. Operators ending in `Queryable` are also supported. You can disable this by setting `disableLinqQueryableSupport` to `true`. `AsyncEnumerable` from the [`System.Linq.Async`](https://www.nuget.org/packages/System.Linq.Async) package is also recognized.
404404

405405
Async operator names suffixed with `Async`, `Await`, or `AwaitWithCancellation` are normalized to their synchronous counterparts so built-in exception knowledge applies.
406406

@@ -491,6 +491,16 @@ var query = items.Where([Throws(typeof(FormatException), typeof(OverflowExceptio
491491
var r = query.First();
492492
```
493493

494+
### Disable LINQ IQueryable support
495+
496+
This option disables analysis of LINQ operators defined on `Queryable`. Expression tree translation depends on the provider, so disable this when your provider behaves differently.
497+
498+
```json
499+
{
500+
"disableLinqQueryableSupport": true
501+
}
502+
```
503+
494504
#### Disable implicitly declared exceptions in lambdas
495505

496506
This option control whether to disable implicitly declared exceptions in lambdas passed into LINQ operator methods.

0 commit comments

Comments
 (0)