Skip to content

Commit d4f7ada

Browse files
authored
Merge pull request #175 from koenbeuk/feature/memberbody-stricter
UseMemberBody more strict with expressions
2 parents 77bae15 + 6f81bdd commit d4f7ada

16 files changed

Lines changed: 1067 additions & 185 deletions

README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ Multiple `[Projectable]` constructors (overloads) per class are fully supported.
316316

317317
#### Can I redirect the expression body to a different member with `UseMemberBody`?
318318

319-
Yes! The `UseMemberBody` property on `[Projectable]` lets you redirect the source of the generated expression to a *different* member on the same type (and in the same file).
319+
Yes! The `UseMemberBody` property on `[Projectable]` lets you redirect the source of the generated expression to a *different* member on the same type.
320320

321321
This is useful when you want to:
322322

@@ -343,6 +343,8 @@ public class Entity
343343

344344
The generated expression is `(@this) => @this.Id * 2`, so `Computed` projects as `Id * 2` in SQL even though the arrow body says `Id`.
345345

346+
> **Note:** When delegating to a regular method or property body the target member must be declared in the **same source file** as the `[Projectable]` member so the generator can read its body.
347+
346348
##### Using an `Expression<Func<...>>` property as the body
347349

348350
For even more control you can supply the body as a typed `Expression<Func<...>>` property. This lets you write the expression once and reuse it from both the `[Projectable]` member and any runtime code that needs the expression tree directly:
@@ -360,9 +362,26 @@ public class Entity
360362
}
361363
```
362364

363-
> **Note:** When the projectable member is a *property*, the `Expression<Func<...>>` property body is handled entirely by the runtime resolver — no extra source is generated. This works transparently.
365+
Unlike regular method/property delegation, `Expression<Func<...>>` backing properties may be declared in a **different file** — for example in a separate part of a `partial class`:
366+
367+
```csharp
368+
// File: Entity.cs
369+
public partial class Entity
370+
{
371+
public int Id { get; set; }
364372

365-
For **instance methods**, name the lambda parameter `@this` so that it matches the generator's own naming convention:
373+
[Projectable(UseMemberBody = nameof(IdDoubledExpr))]
374+
public int Computed => Id;
375+
}
376+
377+
// File: Entity.Expressions.cs
378+
public partial class Entity
379+
{
380+
private static Expression<Func<Entity, int>> IdDoubledExpr => @this => @this.Id * 2;
381+
}
382+
```
383+
384+
For **instance methods**, the generator automatically aligns lambda parameter names with the method's own parameter names, so you are free to choose any names in the lambda. Using `@this` for the receiver is conventional and avoids any renaming:
366385

367386
```csharp
368387
public class Entity
@@ -372,10 +391,19 @@ public class Entity
372391
[Projectable(UseMemberBody = nameof(IsPositiveExpr))]
373392
public bool IsPositive() => Value > 0;
374393

394+
// Any receiver name works; @this is conventional
375395
private static Expression<Func<Entity, bool>> IsPositiveExpr => @this => @this.Value > 0;
376396
}
377397
```
378398

399+
If the lambda parameter names differ from the method's parameter names the generator renames them automatically:
400+
401+
```csharp
402+
// Lambda uses (c, t) but method parameter is named threshold — generated code uses threshold
403+
private static Expression<Func<Entity, int, bool>> ExceedsThresholdExpr =>
404+
(c, t) => c.Value > t;
405+
```
406+
379407
##### Static extension methods
380408

381409
`UseMemberBody` works equally well on static extension methods. Name the lambda parameters to match the method's parameter names:

src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs

Lines changed: 175 additions & 122 deletions
Large diffs are not rendered by default.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using Microsoft.CodeAnalysis.CSharp.Syntax;
4+
5+
namespace EntityFrameworkCore.Projectables.Generator;
6+
7+
public static partial class ProjectableInterpreter
8+
{
9+
/// <summary>
10+
/// Visits <paramref name="parameterList"/> through <paramref name="rewriter"/> and appends
11+
/// all resulting parameters to <see cref="ProjectableDescriptor.ParametersList"/>.
12+
/// </summary>
13+
private static void ApplyParameterList(
14+
ParameterListSyntax parameterList,
15+
DeclarationSyntaxRewriter rewriter,
16+
ProjectableDescriptor descriptor)
17+
{
18+
foreach (var p in ((ParameterListSyntax)rewriter.Visit(parameterList)).Parameters)
19+
{
20+
descriptor.ParametersList = descriptor.ParametersList!.AddParameters(p);
21+
}
22+
}
23+
24+
/// <summary>
25+
/// Visits the type-parameter list and constraint clauses of <paramref name="methodDecl"/>
26+
/// through <paramref name="rewriter"/> and stores them on <paramref name="descriptor"/>.
27+
/// </summary>
28+
private static void ApplyTypeParameters(
29+
MethodDeclarationSyntax methodDecl,
30+
DeclarationSyntaxRewriter rewriter,
31+
ProjectableDescriptor descriptor)
32+
{
33+
if (methodDecl.TypeParameterList is not null)
34+
{
35+
descriptor.TypeParameterList = SyntaxFactory.TypeParameterList();
36+
foreach (var tp in ((TypeParameterListSyntax)rewriter.Visit(methodDecl.TypeParameterList)).Parameters)
37+
{
38+
descriptor.TypeParameterList = descriptor.TypeParameterList.AddParameters(tp);
39+
}
40+
}
41+
42+
if (methodDecl.ConstraintClauses.Any())
43+
{
44+
descriptor.ConstraintClauses = SyntaxFactory.List(
45+
methodDecl.ConstraintClauses
46+
.Select(x => (TypeParameterConstraintClauseSyntax)rewriter.Visit(x)));
47+
}
48+
}
49+
50+
/// <summary>
51+
/// Returns the readable getter expression from a property declaration, trying in order:
52+
/// the property-level expression-body, the getter's expression-body, then the first
53+
/// <see langword="return"/> expression in a block-bodied getter.
54+
/// Returns <c>null</c> when none of these are present.
55+
/// </summary>
56+
private static ExpressionSyntax? TryGetPropertyGetterExpression(PropertyDeclarationSyntax prop)
57+
{
58+
if (prop.ExpressionBody?.Expression is { } exprBody)
59+
{
60+
return exprBody;
61+
}
62+
63+
if (prop.AccessorList is not null)
64+
{
65+
var getter = prop.AccessorList.Accessors
66+
.FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration));
67+
68+
if (getter?.ExpressionBody?.Expression is { } getterExpr)
69+
{
70+
return getterExpr;
71+
}
72+
73+
if (getter?.Body?.Statements.OfType<ReturnStatementSyntax>().FirstOrDefault()?.Expression is { } returnExpr)
74+
{
75+
return returnExpr;
76+
}
77+
}
78+
79+
return null;
80+
}
81+
82+
/// <summary>
83+
/// Reports <see cref="Diagnostics.RequiresBodyDefinition"/> for <paramref name="node"/>
84+
/// and returns <c>false</c> so callers can write <c>return ReportRequiresBodyAndFail(…)</c>.
85+
/// </summary>
86+
private static bool ReportRequiresBodyAndFail(
87+
SourceProductionContext context,
88+
SyntaxNode node,
89+
string memberName)
90+
{
91+
context.ReportDiagnostic(Diagnostic.Create(
92+
Diagnostics.RequiresBodyDefinition,
93+
node.GetLocation(),
94+
memberName));
95+
return false;
96+
}
97+
}
98+

src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs

Lines changed: 123 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -67,64 +67,133 @@ x is IPropertySymbol xProperty &&
6767
}).ToList();
6868

6969
// Expression-property candidates: a property returning Expression<TDelegate>.
70-
// Supported in the generator only when the projectable member is a method.
71-
// When the projectable member is a property, the runtime resolver handles it.
72-
var exprPropertyCandidates = memberSymbol is IMethodSymbol
73-
? allCandidates.Where(IsExpressionDelegateProperty).ToList()
74-
: [];
75-
76-
// Filter Expression<TDelegate> candidates whose Func generic-argument count is
77-
// compatible with the projectable method's parameter list.
70+
// Supported in the generator for both projectable methods and projectable properties.
71+
var exprPropertyCandidates = allCandidates.Where(IsExpressionDelegateProperty).ToList();
72+
73+
// Filter Expression<TDelegate> candidates whose Func generic-argument count, return type,
74+
// and parameter types are all compatible with the projectable member's signature.
7875
List<ISymbol> compatibleExprPropertyCandidates = [];
79-
if (exprPropertyCandidates.Count > 0 && memberSymbol is IMethodSymbol exprCheckMethod)
76+
if (exprPropertyCandidates.Count > 0)
8077
{
81-
var isExtensionBlock = memberSymbol.ContainingType is { IsExtension: true };
82-
var hasImplicitThis = !exprCheckMethod.IsStatic || isExtensionBlock;
83-
var expectedFuncArgCount = exprCheckMethod.Parameters.Length + (hasImplicitThis ? 2 : 1);
84-
85-
compatibleExprPropertyCandidates = exprPropertyCandidates.Where(x =>
78+
if (memberSymbol is IMethodSymbol exprCheckMethod)
8679
{
87-
if (x is not IPropertySymbol propSym)
88-
{
89-
return false;
90-
}
80+
var isExtensionBlock = memberSymbol.ContainingType is { IsExtension: true };
81+
var hasImplicitThis = !exprCheckMethod.IsStatic || isExtensionBlock;
82+
var expectedFuncArgCount = exprCheckMethod.Parameters.Length + (hasImplicitThis ? 2 : 1);
9183

92-
if (propSym.Type is not INamedTypeSymbol exprType || exprType.TypeArguments.Length != 1)
84+
// Determine the expected receiver type when hasImplicitThis is true.
85+
// For extension-block members the receiver is the extension parameter's type;
86+
// for ordinary instance methods it is the containing type.
87+
ITypeSymbol? expectedReceiverType = null;
88+
if (hasImplicitThis)
9389
{
94-
return false;
90+
expectedReceiverType = isExtensionBlock
91+
? exprCheckMethod.ContainingType.ExtensionParameter?.Type
92+
: exprCheckMethod.ContainingType;
9593
}
9694

97-
if (exprType.TypeArguments[0] is not INamedTypeSymbol delegateType)
95+
compatibleExprPropertyCandidates = exprPropertyCandidates.Where(x =>
9896
{
99-
return false;
100-
}
97+
if (x is not IPropertySymbol propSym)
98+
{
99+
return false;
100+
}
101101

102-
return delegateType.TypeArguments.Length == expectedFuncArgCount;
103-
}).ToList();
104-
}
102+
if (propSym.Type is not INamedTypeSymbol exprType || exprType.TypeArguments.Length != 1)
103+
{
104+
return false;
105+
}
105106

106-
// Step 3: if no generator-handled candidates exist, diagnose or skip
107-
if (regularCompatible.Count == 0 && compatibleExprPropertyCandidates.Count == 0)
108-
{
109-
// Expression properties were found but all have incompatible Func signatures.
110-
if (exprPropertyCandidates.Count > 0)
111-
{
112-
context.ReportDiagnostic(Diagnostic.Create(
113-
Diagnostics.UseMemberBodyIncompatible,
114-
member.GetLocation(),
115-
memberSymbol.Name,
116-
useMemberBody));
117-
return null;
118-
}
107+
if (exprType.TypeArguments[0] is not INamedTypeSymbol delegateType)
108+
{
109+
return false;
110+
}
111+
112+
if (delegateType.TypeArguments.Length != expectedFuncArgCount)
113+
{
114+
return false;
115+
}
116+
117+
// Receiver-type check: when hasImplicitThis is true, TypeArguments[0] must match
118+
// the implicit receiver — the containing type for instance methods, or the extension
119+
// receiver type for extension-block members.
120+
if (hasImplicitThis && expectedReceiverType is not null)
121+
{
122+
if (!comparer.Equals(delegateType.TypeArguments[0], expectedReceiverType))
123+
{
124+
return false;
125+
}
126+
}
127+
128+
// Return-type check: the last type argument of the delegate must match the method's return type.
129+
var delegateReturnType = delegateType.TypeArguments[delegateType.TypeArguments.Length - 1];
130+
if (!comparer.Equals(delegateReturnType, exprCheckMethod.ReturnType))
131+
{
132+
return false;
133+
}
134+
135+
// Parameter-type checks: each explicit parameter type must match.
136+
// When hasImplicitThis is true, TypeArguments[0] is the implicit receiver — skip it.
137+
var paramOffset = hasImplicitThis ? 1 : 0;
138+
for (var i = 0; i < exprCheckMethod.Parameters.Length; i++)
139+
{
140+
if (!comparer.Equals(delegateType.TypeArguments[paramOffset + i], exprCheckMethod.Parameters[i].Type))
141+
{
142+
return false;
143+
}
144+
}
119145

120-
// A projectable *property* backed by an Expression<TDelegate> property is
121-
// handled at runtime by ProjectionExpressionResolver; skip silently so the
122-
// runtime path can take over without a spurious error.
123-
if (memberSymbol is IPropertySymbol && allCandidates.Any(IsExpressionDelegateProperty))
146+
return true;
147+
}).ToList();
148+
}
149+
else if (memberSymbol is IPropertySymbol exprCheckProperty)
124150
{
125-
return null;
151+
// Instance property: Func<ContainingType, PropType> — 2 type arguments.
152+
// Static property: Func<PropType> — 1 type argument.
153+
var expectedFuncArgCount = exprCheckProperty.IsStatic ? 1 : 2;
154+
155+
compatibleExprPropertyCandidates = exprPropertyCandidates.Where(x =>
156+
{
157+
if (x is not IPropertySymbol propSym)
158+
{
159+
return false;
160+
}
161+
162+
if (propSym.Type is not INamedTypeSymbol exprType || exprType.TypeArguments.Length != 1)
163+
{
164+
return false;
165+
}
166+
167+
if (exprType.TypeArguments[0] is not INamedTypeSymbol delegateType)
168+
{
169+
return false;
170+
}
171+
172+
if (delegateType.TypeArguments.Length != expectedFuncArgCount)
173+
{
174+
return false;
175+
}
176+
177+
// For instance properties, the first delegate type argument must be the containing type
178+
// (the implicit receiver).
179+
if (!exprCheckProperty.IsStatic)
180+
{
181+
var receiverType = delegateType.TypeArguments[0];
182+
if (!comparer.Equals(receiverType, exprCheckProperty.ContainingType))
183+
{
184+
return false;
185+
}
186+
}
187+
// Return-type check: the last type argument of the delegate must match the property type.
188+
var delegateReturnType = delegateType.TypeArguments[delegateType.TypeArguments.Length - 1];
189+
return comparer.Equals(delegateReturnType, exprCheckProperty.Type);
190+
}).ToList();
126191
}
192+
}
127193

194+
// Step 3: if no generator-handled candidates exist, diagnose
195+
if (regularCompatible.Count == 0 && compatibleExprPropertyCandidates.Count == 0)
196+
{
128197
context.ReportDiagnostic(Diagnostic.Create(
129198
Diagnostics.UseMemberBodyIncompatible,
130199
member.GetLocation(),
@@ -165,6 +234,9 @@ x is IPropertySymbol xProperty &&
165234
// These don't need to share the member's static modifier because a
166235
// static Expression<Func<...>> property can legitimately back either
167236
// a static or an instance projectable method.
237+
// They are also allowed to live in a different file (e.g. a split partial class):
238+
// a direct lambda body can be extracted purely syntactically without a shared
239+
// SemanticModel; the runtime fallback handles any cases the generator can't inline.
168240
if (resolvedBody is null && compatibleExprPropertyCandidates.Count > 0)
169241
{
170242
resolvedBody = compatibleExprPropertyCandidates
@@ -173,7 +245,7 @@ x is IPropertySymbol xProperty &&
173245
.OfType<MemberDeclarationSyntax>()
174246
.FirstOrDefault(x =>
175247
{
176-
if (x == null || x.SyntaxTree != member.SyntaxTree)
248+
if (x == null)
177249
{
178250
return false;
179251
}
@@ -198,25 +270,17 @@ x is IPropertySymbol xProperty &&
198270
}
199271

200272
/// <summary>Returns true when a <see cref="PropertyDeclarationSyntax"/> has a readable body.</summary>
201-
private static bool HasReadablePropertyBody(PropertyDeclarationSyntax xProp)
273+
private static bool HasReadablePropertyBody(PropertyDeclarationSyntax prop)
202274
{
203-
if (xProp.ExpressionBody is not null)
275+
if (prop.ExpressionBody is not null)
204276
{
205277
return true;
206278
}
207279

208-
if (xProp.AccessorList is not null)
209-
{
210-
var getter = xProp.AccessorList.Accessors
211-
.FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration));
212-
213-
if (getter?.ExpressionBody is not null || getter?.Body is not null)
214-
{
215-
return true;
216-
}
217-
}
218-
219-
return false;
280+
var getter = prop.AccessorList?.Accessors
281+
.FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration));
282+
283+
return getter?.ExpressionBody is not null || getter?.Body is not null;
220284
}
221285

222286
/// <summary>Returns true when a symbol is a property returning <c>Expression&lt;TDelegate&gt;</c>.</summary>

0 commit comments

Comments
 (0)