Skip to content

Commit b704352

Browse files
authored
Merge pull request #48 from EFNext/feat/smart-diagnostics
Add NotExpressive attribute and related analyzer
2 parents aeaacfb + 994d9d0 commit b704352

12 files changed

Lines changed: 1440 additions & 64 deletions

docs/reference/diagnostics.md

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ See [Troubleshooting](./troubleshooting) for symptom-oriented guidance -- find t
3232
| [EXP0017](#exp0017) | Error | `[ExpressiveFor]` return type mismatch | -- |
3333
| [EXP0019](#exp0019) | Error | `[ExpressiveFor]` conflicts with `[Expressive]` | -- |
3434
| [EXP0020](#exp0020) | Error | Duplicate `[ExpressiveFor]` mapping | -- |
35+
| [EXP0027](#exp0027) | Info | Plain `IQueryable` chain references an `[Expressive]` member without `.AsExpressive()` | [Wrap with `.AsExpressive()`](#exp0027-fix) |
3536
| [EXP0031](#exp0031) | Error | `[ExpressiveProperty]` target name is already defined | -- |
3637
| [EXP0032](#exp0032) | Error | `[ExpressiveProperty]` requires a partial containing type | -- |
3738
| [EXP0033](#exp0033) | Error | `[ExpressiveProperty]` requires an expression-bodied property stub | -- |
3839
| [EXP0034](#exp0034) | Error | `[ExpressiveProperty]` requires an instance stub | -- |
3940
| [EXP0035](#exp0035) | Error | `[ExpressiveProperty]` target shadows inherited member | -- |
41+
| [EXP0036](#exp0036) | Info | `IExpressiveQueryable<T>` chain dropped to plain `IQueryable<T>` | -- |
4042
| [EXP1001](#exp1001) | Warning | Replace `[Projectable]` with `[Expressive]` | [Replace attribute](#exp1001-fix) |
4143
| [EXP1002](#exp1002) | Warning | Replace `UseProjectables()` with `UseExpressives()` | [Replace method call](#exp1002-fix) |
4244
| [EXP1003](#exp1003) | Warning | Replace Projectables namespace | [Replace namespace](#exp1003-fix) |
@@ -475,12 +477,45 @@ Duplicate [ExpressiveFor] mapping for member '{0}' on type '{1}'; only one stub
475477

476478
---
477479

480+
### EXP0027 -- Plain `IQueryable` chain references an `[Expressive]` member without `.AsExpressive()` {#exp0027}
481+
482+
**Severity:** Info
483+
**Category:** Usage
484+
485+
**Message:**
486+
```
487+
LINQ method '{0}' on a plain IQueryable<T> references the [Expressive] member '{1}'.
488+
Without .AsExpressive(), the member's body will not be inlined into the expression tree;
489+
the provider may evaluate the call in memory or fail to translate it. Wrap the source
490+
with .AsExpressive().
491+
```
492+
493+
**Cause:** A LINQ method on a plain `IQueryable<T>` receiver (one that is not `IExpressiveQueryable<T>`) is invoked with a lambda whose body references an `[Expressive]` member. Because the chain is not expressive-aware, the source generator does not rewrite the lambda into an expression tree that inlines the member's body — the underlying query provider receives a call to the runtime delegate. Most providers cannot translate this and will either evaluate the call client-side (silent overfetch) or throw at execution time.
494+
495+
**Fix:** Wrap the chain root with `.AsExpressive()` so that subsequent LINQ methods flow through the ExpressiveSharp delegate-based overloads, which inline `[Expressive]` member bodies at compile time.
496+
497+
```csharp
498+
// Before — IsAdult is silently evaluated on the client.
499+
var adults = users.Where(u => u.IsAdult).ToList();
500+
501+
// After — IsAdult is inlined into the expression tree before the provider sees it.
502+
var adults = users.AsExpressive().Where(u => u.IsAdult).ToList();
503+
```
504+
505+
When you intentionally want to evaluate a member at runtime (e.g., it captures process state), mark the member with `[NotExpressive]` to suppress the diagnostic at every call site.
506+
507+
#### Code Fix: Wrap source with `.AsExpressive()` {#exp0027-fix}
508+
509+
The IDE offers a single code action: **Wrap source with `.AsExpressive()`**. It walks the LINQ chain to the leftmost non-LINQ expression, wraps it with `.AsExpressive()`, and inserts `using ExpressiveSharp;` if it is not already imported.
510+
511+
---
512+
478513
## `[ExpressiveProperty]` Diagnostics (EXP0031--EXP0035)
479514

480515
These diagnostics apply to `[ExpressiveProperty]` stubs, which ask the generator to emit a new property on the stub's containing partial type. See [`[ExpressiveProperty]` Attribute](./expressive-property) for the full feature reference.
481516

482517
::: info Replacing `[Expressive(Projectable = true)]`
483-
`[ExpressiveProperty]` replaces the now-removed `[Expressive(Projectable = true)]`. Diagnostic codes `EXP0021`--`EXP0030` were retired along with that feature and are not reused. The migration recipe is in [Migration from Projectables](../guide/migration-from-projectables#migrating-usememberbody).
518+
`[ExpressiveProperty]` replaces the now-removed `[Expressive(Projectable = true)]`. Diagnostic codes `EXP0021`--`EXP0026` and `EXP0028`--`EXP0030` were retired along with that feature and are not reused. (EXP0027 has been reassigned to the [plain-IQueryable analyzer](#exp0027).) The migration recipe is in [Migration from Projectables](../guide/migration-from-projectables#migrating-usememberbody).
484519
:::
485520

486521
### EXP0031 -- Target name is already defined {#exp0031}
@@ -611,6 +646,57 @@ on an override
611646

612647
---
613648

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+
614700
## Migration Diagnostics (EXP1001--EXP1003)
615701

616702
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.

docs/reference/expressive-attribute.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,27 @@ ExpressiveOptions.Default.AddTransformers(new RemoveNullConditionalPatterns());
136136
expr.ExpandExpressives(); // RemoveNullConditionalPatterns applied automatically
137137
```
138138

139+
## Opting Out: `[NotExpressive]`
140+
141+
Use `[NotExpressive]` to mark a member that *looks* expressive-eligible (it has an expression body that the source generator could lift) but should intentionally remain runtime-evaluated. The attribute suppresses the analyzer suggestions:
142+
143+
- [EXP0013](./diagnostics#exp0013) — "Member could benefit from `[Expressive]`"
144+
- [EXP0027](./diagnostics#exp0027) — "Plain `IQueryable` chain references an `[Expressive]` member without `.AsExpressive()`"
145+
146+
```csharp
147+
public class Order
148+
{
149+
public Guid Id { get; set; }
150+
151+
// Always evaluated in-memory — captures process-local state that would not
152+
// survive translation. Suppress the "could be [Expressive]" suggestion.
153+
[NotExpressive]
154+
public string DebugLabel => $"{Id} (pid {System.Environment.ProcessId})";
155+
}
156+
```
157+
158+
`[NotExpressive]` cannot be combined with `[Expressive]` on the same member.
159+
139160
## Complete Example
140161

141162
::: expressive-sample
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace ExpressiveSharp;
2+
3+
/// <summary>
4+
/// Suppresses ExpressiveSharp diagnostics that suggest decorating this member with
5+
/// <see cref="ExpressiveAttribute"/> or wrapping a query in <c>.AsExpressive()</c>.
6+
/// </summary>
7+
/// <remarks>
8+
/// Use this attribute on members whose bodies look expandable but should intentionally
9+
/// remain runtime-evaluated — for example, when the body relies on side effects, captures
10+
/// state that would not survive translation, or is deliberately client-side.
11+
/// </remarks>
12+
[AttributeUsage(
13+
AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Constructor,
14+
Inherited = true, AllowMultiple = false)]
15+
public sealed class NotExpressiveAttribute : Attribute
16+
{
17+
}
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+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Threading;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp.Syntax;
4+
5+
namespace ExpressiveSharp.CodeFixers.Internal;
6+
7+
/// <summary>
8+
/// Shared classification helpers used by the diagnostics that reason about
9+
/// <c>[Expressive]</c>-eligible members and Expressive query chains.
10+
/// </summary>
11+
internal static class ExpressiveSymbolHelpers
12+
{
13+
public static bool HasExpressiveAttribute(ISymbol symbol)
14+
=> HasAttribute(symbol, "ExpressiveAttribute");
15+
16+
public static bool HasNotExpressiveAttribute(ISymbol symbol)
17+
=> HasAttribute(symbol, "NotExpressiveAttribute");
18+
19+
private static bool HasAttribute(ISymbol symbol, string attributeTypeName)
20+
{
21+
foreach (var attr in symbol.GetAttributes())
22+
{
23+
var attrClass = attr.AttributeClass;
24+
if (attrClass is null)
25+
continue;
26+
if (attrClass.Name == attributeTypeName &&
27+
attrClass.ContainingNamespace?.ToDisplayString() == "ExpressiveSharp")
28+
return true;
29+
}
30+
return false;
31+
}
32+
33+
/// <summary>
34+
/// Returns true when the symbol declares an inspectable body (expression-bodied or block-bodied)
35+
/// that the source generator could lift into an expression tree if <c>[Expressive]</c> were applied.
36+
/// </summary>
37+
public static bool HasExpandableBody(ISymbol symbol, CancellationToken ct)
38+
{
39+
foreach (var syntaxRef in symbol.DeclaringSyntaxReferences)
40+
{
41+
var syntax = syntaxRef.GetSyntax(ct);
42+
43+
if (syntax is MethodDeclarationSyntax methodDecl)
44+
{
45+
if (methodDecl.ExpressionBody is not null || methodDecl.Body is not null)
46+
return true;
47+
}
48+
else if (syntax is PropertyDeclarationSyntax propDecl)
49+
{
50+
if (propDecl.ExpressionBody is not null)
51+
return true;
52+
53+
if (propDecl.AccessorList is not null)
54+
{
55+
foreach (var accessor in propDecl.AccessorList.Accessors)
56+
{
57+
if (accessor.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.GetAccessorDeclaration) &&
58+
(accessor.ExpressionBody is not null || accessor.Body is not null))
59+
return true;
60+
}
61+
}
62+
}
63+
}
64+
return false;
65+
}
66+
67+
/// <summary>
68+
/// Returns true when the type is or implements <c>ExpressiveSharp.IExpressiveQueryable&lt;T&gt;</c>.
69+
/// </summary>
70+
public static bool IsOrImplementsExpressiveQueryable(ITypeSymbol type)
71+
{
72+
if (IsExpressiveQueryableType(type))
73+
return true;
74+
75+
foreach (var iface in type.AllInterfaces)
76+
{
77+
if (IsExpressiveQueryableType(iface))
78+
return true;
79+
}
80+
81+
return false;
82+
}
83+
84+
private static bool IsExpressiveQueryableType(ITypeSymbol type) =>
85+
type.Name == "IExpressiveQueryable" &&
86+
type.ContainingNamespace?.ToDisplayString() == "ExpressiveSharp";
87+
}

0 commit comments

Comments
 (0)