Skip to content

Commit 994d9d0

Browse files
koenbeukclaude
andcommitted
feat(analyzers): EXP0036 — flag IExpressiveQueryable<T> chain dropouts
ExpressiveQueryableDropoutAnalyzer fires once at the call site where a chain leaves IExpressiveQueryable<T> and returns plain IQueryable<T>, surfacing the root cause instead of the downstream symptoms. Catches user-defined helpers that take/return IQueryable<T>, third-party extensions, and other implicit upcasts that silently strip the ExpressiveSharp rewrite layer. Exemptions: .AsQueryable() (sanctioned downcast), [NotExpressive] on the offending method (intentional dropout), terminating calls whose result is not IQueryable<T>, and methods whose result is still IExpressiveQueryable<T> (chain preserved). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 39d336d commit 994d9d0

3 files changed

Lines changed: 442 additions & 0 deletions

File tree

docs/reference/diagnostics.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t
3838
| [EXP0033](#exp0033) | Error | `[ExpressiveProperty]` requires an expression-bodied property stub | -- |
3939
| [EXP0034](#exp0034) | Error | `[ExpressiveProperty]` requires an instance stub | -- |
4040
| [EXP0035](#exp0035) | Error | `[ExpressiveProperty]` target shadows inherited member | -- |
41+
| [EXP0036](#exp0036) | Info | `IExpressiveQueryable<T>` chain dropped to plain `IQueryable<T>` | -- |
4142
| [EXP1001](#exp1001) | Warning | Replace `[Projectable]` with `[Expressive]` | [Replace attribute](#exp1001-fix) |
4243
| [EXP1002](#exp1002) | Warning | Replace `UseProjectables()` with `UseExpressives()` | [Replace method call](#exp1002-fix) |
4344
| [EXP1003](#exp1003) | Warning | Replace Projectables namespace | [Replace namespace](#exp1003-fix) |
@@ -645,6 +646,57 @@ on an override
645646

646647
---
647648

649+
### EXP0036 -- `IExpressiveQueryable<T>` chain dropped to plain `IQueryable<T>` {#exp0036}
650+
651+
**Severity:** Info
652+
**Category:** Usage
653+
654+
**Message:**
655+
```
656+
'{0}' returns IQueryable<T> from an IExpressiveQueryable<T> receiver, dropping the expressive
657+
chain. Downstream LINQ skips ExpressiveSharp rewriting and [Expressive] members may evaluate
658+
on the client. Add an IExpressiveQueryable<T>-typed overload of '{0}', wrap the result with
659+
.AsExpressive(), or mark the method [NotExpressive] if the dropout is intentional.
660+
```
661+
662+
**Cause:** A method invocation is being made on a receiver that implements `IExpressiveQueryable<T>`, but the method's return type is plain `IQueryable<T>` (or some derivative that is not also expressive). The chain loses its expressive type at this call site, and every downstream LINQ operation on the result skips ExpressiveSharp's rewrite step — `[Expressive]` members in subsequent `Where` / `Select` / `Include` / etc. fall back to runtime delegate invocation, which most providers cannot translate and will evaluate on the client.
663+
664+
The diagnostic fires once, at the dropout point itself, regardless of how many further calls follow.
665+
666+
**Common dropout shapes:**
667+
668+
```csharp
669+
// User-defined helper typed on plain IQueryable<T> — drops the chain.
670+
public static IQueryable<T> Filter<T>(this IQueryable<T> source) => source.Where(...);
671+
672+
db.Orders.AsExpressiveDbSet().Filter() // ⚠ EXP0036 fires on .Filter()
673+
.Include(o => o.Customer) // chain is plain from here on
674+
.ToList();
675+
```
676+
677+
**Fix (preferred):** Add a sibling overload typed on `IExpressiveQueryable<T>` that returns `IExpressiveQueryable<T>`:
678+
679+
```csharp
680+
public static IExpressiveQueryable<T> Filter<T>(this IExpressiveQueryable<T> source)
681+
=> source.Where(...);
682+
```
683+
684+
**Fix (when the helper can't be modified):** wrap the result with `.AsExpressive()` to restore the chain:
685+
686+
```csharp
687+
db.Orders.AsExpressiveDbSet()
688+
.ThirdPartyHelper() // returns plain IQueryable<Order>
689+
.AsExpressive() // re-wrap
690+
.Include(o => o.Customer);
691+
```
692+
693+
**Exemptions:**
694+
695+
- `.AsQueryable()` — the standard explicit downcast — is sanctioned and never reported.
696+
- Marking the offending method with `[NotExpressive]` suppresses the diagnostic at every call site, for cases where the dropout is intentional (the helper performs work that genuinely needs to run on the client).
697+
698+
---
699+
648700
## Migration Diagnostics (EXP1001--EXP1003)
649701

650702
These diagnostics are emitted by the `MigrationAnalyzer` in the `ExpressiveSharp.EntityFrameworkCore.CodeFixers` package. They detect usage of the legacy `EntityFrameworkCore.Projectables` library and offer automated code fixes to migrate to ExpressiveSharp.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using System.Collections.Immutable;
2+
using ExpressiveSharp.CodeFixers.Internal;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using Microsoft.CodeAnalysis.Diagnostics;
7+
8+
namespace ExpressiveSharp.CodeFixers;
9+
10+
/// <summary>
11+
/// Reports EXP0036 at the *dropout call* itself: a method invocation whose receiver implements
12+
/// <c>IExpressiveQueryable&lt;T&gt;</c> but whose return type is plain <c>IQueryable&lt;T&gt;</c>.
13+
/// The chain stops being expressive at this call; downstream LINQ skips ExpressiveSharp rewriting.
14+
/// </summary>
15+
/// <remarks>
16+
/// Exemptions:
17+
/// <list type="bullet">
18+
/// <item><description><c>AsQueryable()</c> — sanctioned explicit downcast.</description></item>
19+
/// <item><description>Methods marked <c>[NotExpressive]</c> — opt-out for intentional dropouts.</description></item>
20+
/// </list>
21+
/// </remarks>
22+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
23+
public sealed class ExpressiveQueryableDropoutAnalyzer : DiagnosticAnalyzer
24+
{
25+
public static readonly DiagnosticDescriptor ExpressiveQueryableDropout = new(
26+
id: "EXP0036",
27+
title: "IExpressiveQueryable<T> chain dropped to plain IQueryable<T>",
28+
messageFormat: "'{0}' returns IQueryable<T> from an IExpressiveQueryable<T> receiver, dropping the expressive chain. Downstream LINQ skips ExpressiveSharp rewriting and [Expressive] members may evaluate on the client. Add an IExpressiveQueryable<T>-typed overload of '{0}', wrap the result with .AsExpressive(), or mark the method [NotExpressive] if the dropout is intentional.",
29+
category: "Usage",
30+
defaultSeverity: DiagnosticSeverity.Info,
31+
isEnabledByDefault: true,
32+
description: "Once an IExpressiveQueryable<T> chain is upcast back to plain IQueryable<T>, the ExpressiveSharp rewrite layer is no longer applied to subsequent calls. User-defined helpers that take and return IQueryable<T> are the most common cause; sibling overloads typed on IExpressiveQueryable<T> preserve the chain.");
33+
34+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
35+
ImmutableArray.Create(ExpressiveQueryableDropout);
36+
37+
public override void Initialize(AnalysisContext context)
38+
{
39+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
40+
context.EnableConcurrentExecution();
41+
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
42+
}
43+
44+
private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
45+
{
46+
var invocation = (InvocationExpressionSyntax)context.Node;
47+
48+
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
49+
return;
50+
51+
var calledName = memberAccess.Name.Identifier.Text;
52+
53+
// Sanctioned explicit downcast — user knows exactly what they're doing.
54+
if (calledName == "AsQueryable")
55+
return;
56+
57+
var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken);
58+
if (symbolInfo.Symbol is not IMethodSymbol method)
59+
return;
60+
61+
// Method-level opt-out. Honored same as EXP0027 honors it on referenced members.
62+
if (ExpressiveSymbolHelpers.HasNotExpressiveAttribute(method))
63+
return;
64+
65+
var receiverType = context.SemanticModel.GetTypeInfo(
66+
memberAccess.Expression, context.CancellationToken).Type;
67+
if (receiverType is null)
68+
return;
69+
if (!ExpressiveSymbolHelpers.IsOrImplementsExpressiveQueryable(receiverType))
70+
return;
71+
72+
var resultType = context.SemanticModel.GetTypeInfo(invocation, context.CancellationToken).Type;
73+
if (resultType is null)
74+
return;
75+
76+
// Terminating calls (.ToList, .Count, scalars, void) don't continue the chain — no dropout.
77+
if (!ImplementsIQueryable(resultType))
78+
return;
79+
80+
// Still expressive — chain preserved, no dropout.
81+
if (ExpressiveSymbolHelpers.IsOrImplementsExpressiveQueryable(resultType))
82+
return;
83+
84+
context.ReportDiagnostic(Diagnostic.Create(
85+
ExpressiveQueryableDropout,
86+
memberAccess.Name.GetLocation(),
87+
properties: null,
88+
calledName));
89+
}
90+
91+
private static bool ImplementsIQueryable(ITypeSymbol type)
92+
{
93+
if (IsIQueryable(type))
94+
return true;
95+
96+
foreach (var iface in type.AllInterfaces)
97+
{
98+
if (IsIQueryable(iface))
99+
return true;
100+
}
101+
102+
return false;
103+
}
104+
105+
private static bool IsIQueryable(ITypeSymbol type) =>
106+
type.Name == "IQueryable" &&
107+
type.ContainingNamespace?.ToDisplayString() == "System.Linq";
108+
}

0 commit comments

Comments
 (0)