From 23109aeb658915b8862fad7860b5fcc19da75c2a Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Mon, 9 Mar 2026 22:35:23 +0100 Subject: [PATCH 1/7] Improve usememberbody strictness --- .../ProjectableInterpreter.BodyProcessors.cs | 125 +++++++++++-- ...ojectableInterpreter.MemberBodyResolver.cs | 129 +++++++++----- .../ProjectableInterpreter.cs | 7 + .../Services/ProjectableExpressionReplacer.cs | 6 + ...PropertyBody_InstanceProperty.verified.txt | 18 ++ .../UseMemberBodyTests.cs | 167 +++++++++++++++++- 6 files changed, 390 insertions(+), 62 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_InstanceProperty.verified.txt diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs index 5ab23fd..f423f85 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs @@ -173,6 +173,82 @@ private static bool TryApplyExpressionPropertyBody( return true; } + /// + /// Fills for a projectable property whose body is + /// delegated to an Expression<TDelegate> property (specified via + /// UseMemberBody). Unwraps the inner lambda and uses the projectable + /// property's own return type. The implicit @this parameter is already + /// added by . + /// Returns false and reports diagnostics on failure. + /// + private static bool TryApplyExpressionPropertyBodyForProperty( + PropertyDeclarationSyntax originalPropertyDecl, + PropertyDeclarationSyntax exprPropDecl, + SemanticModel semanticModel, + MemberDeclarationSyntax member, + ISymbol memberSymbol, + ExpressionSyntaxRewriter expressionSyntaxRewriter, + DeclarationSyntaxRewriter declarationSyntaxRewriter, + SourceProductionContext context, + ProjectableDescriptor descriptor) + { + ExpressionSyntax? innerBody = null; + string? firstParamName = null; + + if (exprPropDecl.ExpressionBody?.Expression is { } exprBodyExpr) + { + // Expression-bodied property: Prop => @this => … OR Prop => storedExpr + (innerBody, firstParamName) = TryExtractLambdaBodyAndFirstParam(exprBodyExpr, semanticModel, member.SyntaxTree); + } + else if (exprPropDecl.AccessorList is not null) + { + var getter = exprPropDecl.AccessorList.Accessors + .FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); + + if (getter?.ExpressionBody?.Expression is { } getterExprBody) + { + // get => @this => … OR get => storedExpr + (innerBody, firstParamName) = TryExtractLambdaBodyAndFirstParam(getterExprBody, semanticModel, member.SyntaxTree); + } + else if (getter?.Body is not null) + { + // Block-bodied getter: get { return @this => …; } or get { return storedExpr; } + var returnStmt = getter.Body.Statements + .OfType() + .FirstOrDefault(); + + (innerBody, firstParamName) = TryExtractLambdaBodyAndFirstParam(returnStmt?.Expression, semanticModel, member.SyntaxTree); + } + } + + if (innerBody is null) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.RequiresBodyDefinition, + exprPropDecl.GetLocation(), + memberSymbol.Name)); + return false; + } + + // The generated lambda always uses @this as the receiver parameter name. + // If the expression property used a different name (e.g. `x => x.Id`), rename it. + // NOTE: renaming must happen AFTER expressionSyntaxRewriter.Visit because the rewriter + // uses the semantic model which requires the original (pre-rename) syntax nodes. + var visitedBody = (ExpressionSyntax)expressionSyntaxRewriter.Visit(innerBody); + if (firstParamName is not null && firstParamName != "@this") + { + visitedBody = (ExpressionSyntax)new VariableReplacementRewriter( + firstParamName, + SyntaxFactory.IdentifierName("@this")).Visit(visitedBody); + } + + var returnType = declarationSyntaxRewriter.Visit(originalPropertyDecl.Type); + descriptor.ReturnTypeName = returnType.ToString(); + descriptor.ExpressionBody = visitedBody; + + return true; + } + /// /// Fills from a property declaration body. /// Returns false and reports diagnostics on failure. @@ -390,17 +466,36 @@ or Accessibility.Internal SemanticModel semanticModel, SyntaxTree memberSyntaxTree, int depth = 0) + => TryExtractLambdaBodyAndFirstParam(expression, semanticModel, memberSyntaxTree, depth).body; + + /// + /// Like , but also returns the first lambda parameter name + /// so callers can rename it (e.g. to @this) in the extracted body. + /// + private static (ExpressionSyntax? body, string? firstParamName) TryExtractLambdaBodyAndFirstParam( + ExpressionSyntax? expression, + SemanticModel semanticModel, + SyntaxTree memberSyntaxTree, + int depth = 0) { if (expression is null || depth > 5) { - return null; + return (null, null); + } + + // Lambda literal → extract its expression body and first parameter name directly. + // Block-bodied lambda yields null body → falls through to EFP0006. + if (expression is SimpleLambdaExpressionSyntax simpleLambda) + { + return (simpleLambda.Body as ExpressionSyntax, simpleLambda.Parameter.Identifier.Text); } - // Lambda literal → extract its expression body directly. - // Block-bodied lambda (e.g. x => { return x > 5; }) yields null → falls through to EFP0006. - if (expression is LambdaExpressionSyntax lambda) + if (expression is ParenthesizedLambdaExpressionSyntax parenLambda) { - return lambda.Body as ExpressionSyntax; + var firstName = parenLambda.ParameterList.Parameters.Count > 0 + ? parenLambda.ParameterList.Parameters[0].Identifier.Text + : null; + return (parenLambda.Body as ExpressionSyntax, firstName); } // Non-lambda: resolve the symbol and follow the reference to its source @@ -408,7 +503,7 @@ or Accessibility.Internal var symbol = semanticModel.GetSymbolInfo(expression).Symbol; if (symbol is not (IFieldSymbol or IPropertySymbol)) { - return null; + return (null, null); } foreach (var syntaxRef in symbol.DeclaringSyntaxReferences) @@ -425,8 +520,8 @@ or Accessibility.Internal // Field: private static readonly Expression> _f = @this => …; if (declSyntax is VariableDeclaratorSyntax { Initializer.Value: var initValue }) { - var result = TryExtractLambdaBody(initValue, semanticModel, memberSyntaxTree, depth + 1); - if (result is not null) + var result = TryExtractLambdaBodyAndFirstParam(initValue, semanticModel, memberSyntaxTree, depth + 1); + if (result.body is not null) { return result; } @@ -437,8 +532,8 @@ or Accessibility.Internal { if (followedProp.ExpressionBody?.Expression is { } followedExprBody) { - var result = TryExtractLambdaBody(followedExprBody, semanticModel, memberSyntaxTree, depth + 1); - if (result is not null) + var result = TryExtractLambdaBodyAndFirstParam(followedExprBody, semanticModel, memberSyntaxTree, depth + 1); + if (result.body is not null) { return result; } @@ -449,26 +544,26 @@ or Accessibility.Internal if (followedGetter?.ExpressionBody?.Expression is { } getterExprBody) { - var result = TryExtractLambdaBody(getterExprBody, semanticModel, memberSyntaxTree, depth + 1); - if (result is not null) + var result = TryExtractLambdaBodyAndFirstParam(getterExprBody, semanticModel, memberSyntaxTree, depth + 1); + if (result.body is not null) { return result; } } - var returnResult = TryExtractLambdaBody( + var returnResult = TryExtractLambdaBodyAndFirstParam( followedGetter?.Body?.Statements .OfType() .FirstOrDefault()?.Expression, semanticModel, memberSyntaxTree, depth + 1); - if (returnResult is not null) + if (returnResult.body is not null) { return returnResult; } } } - return null; + return (null, null); } } \ No newline at end of file diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs index 17344b2..bd30d18 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs @@ -67,64 +67,101 @@ x is IPropertySymbol xProperty && }).ToList(); // Expression-property candidates: a property returning Expression. - // Supported in the generator only when the projectable member is a method. - // When the projectable member is a property, the runtime resolver handles it. - var exprPropertyCandidates = memberSymbol is IMethodSymbol - ? allCandidates.Where(IsExpressionDelegateProperty).ToList() - : []; - - // Filter Expression candidates whose Func generic-argument count is - // compatible with the projectable method's parameter list. + // Supported in the generator for both projectable methods and projectable properties. + var exprPropertyCandidates = allCandidates.Where(IsExpressionDelegateProperty).ToList(); + + // Filter Expression candidates whose Func generic-argument count, return type, + // and parameter types are all compatible with the projectable member's signature. List compatibleExprPropertyCandidates = []; - if (exprPropertyCandidates.Count > 0 && memberSymbol is IMethodSymbol exprCheckMethod) + if (exprPropertyCandidates.Count > 0) { - var isExtensionBlock = memberSymbol.ContainingType is { IsExtension: true }; - var hasImplicitThis = !exprCheckMethod.IsStatic || isExtensionBlock; - var expectedFuncArgCount = exprCheckMethod.Parameters.Length + (hasImplicitThis ? 2 : 1); - - compatibleExprPropertyCandidates = exprPropertyCandidates.Where(x => + if (memberSymbol is IMethodSymbol exprCheckMethod) { - if (x is not IPropertySymbol propSym) - { - return false; - } + var isExtensionBlock = memberSymbol.ContainingType is { IsExtension: true }; + var hasImplicitThis = !exprCheckMethod.IsStatic || isExtensionBlock; + var expectedFuncArgCount = exprCheckMethod.Parameters.Length + (hasImplicitThis ? 2 : 1); - if (propSym.Type is not INamedTypeSymbol exprType || exprType.TypeArguments.Length != 1) + compatibleExprPropertyCandidates = exprPropertyCandidates.Where(x => { - return false; - } + if (x is not IPropertySymbol propSym) + { + return false; + } - if (exprType.TypeArguments[0] is not INamedTypeSymbol delegateType) - { - return false; - } + if (propSym.Type is not INamedTypeSymbol exprType || exprType.TypeArguments.Length != 1) + { + return false; + } - return delegateType.TypeArguments.Length == expectedFuncArgCount; - }).ToList(); - } + if (exprType.TypeArguments[0] is not INamedTypeSymbol delegateType) + { + return false; + } - // Step 3: if no generator-handled candidates exist, diagnose or skip - if (regularCompatible.Count == 0 && compatibleExprPropertyCandidates.Count == 0) - { - // Expression properties were found but all have incompatible Func signatures. - if (exprPropertyCandidates.Count > 0) - { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.UseMemberBodyIncompatible, - member.GetLocation(), - memberSymbol.Name, - useMemberBody)); - return null; - } + if (delegateType.TypeArguments.Length != expectedFuncArgCount) + { + return false; + } - // A projectable *property* backed by an Expression property is - // handled at runtime by ProjectionExpressionResolver; skip silently so the - // runtime path can take over without a spurious error. - if (memberSymbol is IPropertySymbol && allCandidates.Any(IsExpressionDelegateProperty)) + // Return-type check: the last type argument of the delegate must match the method's return type. + var delegateReturnType = delegateType.TypeArguments[delegateType.TypeArguments.Length - 1]; + if (!comparer.Equals(delegateReturnType, exprCheckMethod.ReturnType)) + { + return false; + } + + // Parameter-type checks: each explicit parameter type must match. + // When hasImplicitThis is true, TypeArguments[0] is the implicit receiver — skip it. + var paramOffset = hasImplicitThis ? 1 : 0; + for (var i = 0; i < exprCheckMethod.Parameters.Length; i++) + { + if (!comparer.Equals(delegateType.TypeArguments[paramOffset + i], exprCheckMethod.Parameters[i].Type)) + { + return false; + } + } + + return true; + }).ToList(); + } + else if (memberSymbol is IPropertySymbol exprCheckProperty) { - return null; + // Instance property: Func — 2 type arguments. + // Static property: Func — 1 type argument. + var expectedFuncArgCount = exprCheckProperty.IsStatic ? 1 : 2; + + compatibleExprPropertyCandidates = exprPropertyCandidates.Where(x => + { + if (x is not IPropertySymbol propSym) + { + return false; + } + + if (propSym.Type is not INamedTypeSymbol exprType || exprType.TypeArguments.Length != 1) + { + return false; + } + + if (exprType.TypeArguments[0] is not INamedTypeSymbol delegateType) + { + return false; + } + + if (delegateType.TypeArguments.Length != expectedFuncArgCount) + { + return false; + } + + // Return-type check: the last type argument of the delegate must match the property type. + var delegateReturnType = delegateType.TypeArguments[delegateType.TypeArguments.Length - 1]; + return comparer.Equals(delegateReturnType, exprCheckProperty.Type); + }).ToList(); } + } + // Step 3: if no generator-handled candidates exist, diagnose + if (regularCompatible.Count == 0 && compatibleExprPropertyCandidates.Count == 0) + { context.ReportDiagnostic(Diagnostic.Create( Diagnostics.UseMemberBodyIncompatible, member.GetLocation(), diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index b74cfa2..47f4c60 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -72,6 +72,13 @@ public static partial class ProjectableInterpreter semanticModel, member, memberSymbol, expressionSyntaxRewriter, declarationSyntaxRewriter, context, descriptor), + // Projectable property whose body is an Expression property + (PropertyDeclarationSyntax originalPropertyDecl, PropertyDeclarationSyntax exprPropDecl) + when semanticModel.GetDeclaredSymbol(exprPropDecl) is IPropertySymbol s && IsExpressionDelegateProperty(s) => + TryApplyExpressionPropertyBodyForProperty(originalPropertyDecl, exprPropDecl, + semanticModel, member, memberSymbol, + expressionSyntaxRewriter, declarationSyntaxRewriter, context, descriptor), + // Projectable property (_, PropertyDeclarationSyntax propDecl) => TryApplyPropertyBody(propDecl, allowBlockBody, memberSymbol, diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index e342a6e..346063d 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -316,6 +316,12 @@ private Expression _AddProjectableSelect(Expression node, IEntityType entityType var properties = entityType.GetProperties() .Where(x => !x.IsShadowProperty()) .Select(x => x.GetMemberInfo(false, false)) + .Concat(entityType.GetNavigations() + .Where(x => !x.IsShadowProperty()) + .Select(x => x.GetMemberInfo(false, false))) + .Concat(entityType.GetSkipNavigations() + .Where(x => !x.IsShadowProperty()) + .Select(x => x.GetMemberInfo(false, false))) .Concat(entityType.GetNavigations() .Where(x => !x.IsShadowProperty()) .Select(x => x.GetMemberInfo(false, false))) diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_InstanceProperty.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_InstanceProperty.verified.txt new file mode 100644 index 0000000..34f67c5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_InstanceProperty.verified.txt @@ -0,0 +1,18 @@ +// +#nullable disable +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_Computed + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => @this.Id * 2; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs index 937f58c..3711367 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs @@ -260,7 +260,87 @@ class C { return Verifier.Verify(result.GeneratedTrees[0].ToString()); } - // ── Invalid cases ────────────────────────────────────────────────────────── + [Fact] + public Task Property_UsesExpressionPropertyBody_InstanceProperty() + { + var compilation = CreateCompilation(@" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + public int Id { get; set; } + + [Projectable(UseMemberBody = nameof(IdDoubledExpr))] + public int Computed => Id; + + private static Expression> IdDoubledExpr => @this => @this.Id * 2; + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public void Property_UsesExpressionPropertyBody_IncompatibleReturnType_EmitsEFP0011() + { + var compilation = CreateCompilation(@" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + public int Id { get; set; } + + [Projectable(UseMemberBody = nameof(WrongExpr))] + public int Computed => Id; + + // Return type string does not match int + private static Expression> WrongExpr => @this => @this.Id.ToString(); + } +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0011", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Empty(result.GeneratedTrees); + } + + [Fact] + public void Property_UsesExpressionPropertyBody_IncompatibleParameterCount_EmitsEFP0011() + { + var compilation = CreateCompilation(@" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + public int Id { get; set; } + + [Projectable(UseMemberBody = nameof(WrongExpr))] + public int Computed => Id; + + // Func has 1 arg but instance property needs Func (2 args) + private static Expression> WrongExpr => () => 42; + } +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0011", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Empty(result.GeneratedTrees); + } + + [Fact] public void UseMemberBody_MemberNotFound_EmitsEFP0010() @@ -477,5 +557,90 @@ static class EntityExtensions { Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); Assert.Empty(result.GeneratedTrees); } + + [Fact] + public void UseMemberBody_ExpressionProperty_IncompatibleReturnType_EmitsEFP0011() + { + // Parameter count matches (Func) but return type is int instead of bool. + var compilation = CreateCompilation(@" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class Entity { public string Name { get; set; } } + + static class EntityExtensions { + [Projectable(UseMemberBody = nameof(NameEqualsExpr))] + public static bool NameEquals(this Entity a, Entity b) => a.Name == b.Name; + + // Return type int does not match the projectable method's bool + private static Expression> NameEqualsExpr => (a, b) => 1; + } +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0011", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Empty(result.GeneratedTrees); + } + + [Fact] + public void UseMemberBody_ExpressionProperty_IncompatibleParameterType_EmitsEFP0011() + { + // Parameter count and return type match, but the second parameter type is wrong (Other vs Entity). + var compilation = CreateCompilation(@" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class Entity { public string Name { get; set; } } + class Other { public string Name { get; set; } } + + static class EntityExtensions { + [Projectable(UseMemberBody = nameof(NameEqualsExpr))] + public static bool NameEquals(this Entity a, Entity b) => a.Name == b.Name; + + // Second parameter is Other, not Entity + private static Expression> NameEqualsExpr => (a, b) => a.Name == b.Name; + } +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0011", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Empty(result.GeneratedTrees); + } + + [Fact] + public void UseMemberBody_InstanceMethod_ExpressionProperty_IncompatibleReturnType_EmitsEFP0011() + { + // Instance method: Expression> — return type mismatch. + var compilation = CreateCompilation(@" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + public int Value { get; set; } + + [Projectable(UseMemberBody = nameof(IsPositiveExpr))] + public bool IsPositive() => Value > 0; + + // Return type int does not match bool + private static Expression> IsPositiveExpr => @this => @this.Value; + } +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0011", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Empty(result.GeneratedTrees); + } } From c9d5b120e916755958a427650b27ff2453383fa1 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Mon, 9 Mar 2026 22:51:46 +0100 Subject: [PATCH 2/7] Code factorization --- .../ProjectableInterpreter.BodyProcessors.cs | 181 +++--------------- .../ProjectableInterpreter.Helpers.cs | 98 ++++++++++ ...ojectableInterpreter.MemberBodyResolver.cs | 20 +- 3 files changed, 127 insertions(+), 172 deletions(-) create mode 100644 src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.Helpers.cs diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs index f423f85..3e2bb41 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs @@ -47,11 +47,7 @@ private static bool TryApplyMethodBody( } else { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.RequiresBodyDefinition, - methodDeclarationSyntax.GetLocation(), - memberSymbol.Name)); - return false; + return ReportRequiresBodyAndFail(context, methodDeclarationSyntax, memberSymbol.Name); } var returnType = declarationSyntaxRewriter.Visit(methodDeclarationSyntax.ReturnType); @@ -62,28 +58,8 @@ private static bool TryApplyMethodBody( ? (ExpressionSyntax)expressionSyntaxRewriter.Visit(bodyExpression) : bodyExpression; - foreach (var additionalParameter in - ((ParameterListSyntax)declarationSyntaxRewriter.Visit(methodDeclarationSyntax.ParameterList)).Parameters) - { - descriptor.ParametersList = descriptor.ParametersList!.AddParameters(additionalParameter); - } - - if (methodDeclarationSyntax.TypeParameterList is not null) - { - descriptor.TypeParameterList = SyntaxFactory.TypeParameterList(); - foreach (var additionalTypeParameter in - ((TypeParameterListSyntax)declarationSyntaxRewriter.Visit(methodDeclarationSyntax.TypeParameterList)).Parameters) - { - descriptor.TypeParameterList = descriptor.TypeParameterList.AddParameters(additionalTypeParameter); - } - } - - if (methodDeclarationSyntax.ConstraintClauses.Any()) - { - descriptor.ConstraintClauses = SyntaxFactory.List( - methodDeclarationSyntax.ConstraintClauses - .Select(x => (TypeParameterConstraintClauseSyntax)declarationSyntaxRewriter.Visit(x))); - } + ApplyParameterList(methodDeclarationSyntax.ParameterList, declarationSyntaxRewriter, descriptor); + ApplyTypeParameters(methodDeclarationSyntax, declarationSyntaxRewriter, descriptor); return true; } @@ -106,69 +82,22 @@ private static bool TryApplyExpressionPropertyBody( SourceProductionContext context, ProjectableDescriptor descriptor) { - ExpressionSyntax? innerBody = null; - - if (exprPropDecl.ExpressionBody?.Expression is { } exprBodyExpr) - { - // Expression-bodied property: Prop => (x) => … OR Prop => storedExpr - innerBody = TryExtractLambdaBody(exprBodyExpr, semanticModel, member.SyntaxTree); - } - else if (exprPropDecl.AccessorList is not null) - { - var getter = exprPropDecl.AccessorList.Accessors - .FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); - - if (getter?.ExpressionBody?.Expression is { } getterExprBody) - { - // get => (x) => … OR get => storedExpr - innerBody = TryExtractLambdaBody(getterExprBody, semanticModel, member.SyntaxTree); - } - else if (getter?.Body is not null) - { - // Block-bodied getter: get { return (x) => …; } or get { return storedExpr; } - var returnStmt = getter.Body.Statements - .OfType() - .FirstOrDefault(); - - innerBody = TryExtractLambdaBody(returnStmt?.Expression, semanticModel, member.SyntaxTree); - } - } + var rawExpr = TryGetPropertyGetterExpression(exprPropDecl); + var innerBody = rawExpr is not null + ? TryExtractLambdaBody(rawExpr, semanticModel, member.SyntaxTree) + : null; if (innerBody is null) { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.RequiresBodyDefinition, - exprPropDecl.GetLocation(), - memberSymbol.Name)); - return false; + return ReportRequiresBodyAndFail(context, exprPropDecl, memberSymbol.Name); } var returnType = declarationSyntaxRewriter.Visit(originalMethodDecl.ReturnType); descriptor.ReturnTypeName = returnType.ToString(); descriptor.ExpressionBody = (ExpressionSyntax)expressionSyntaxRewriter.Visit(innerBody); - foreach (var additionalParameter in - ((ParameterListSyntax)declarationSyntaxRewriter.Visit(originalMethodDecl.ParameterList)).Parameters) - { - descriptor.ParametersList = descriptor.ParametersList!.AddParameters(additionalParameter); - } - - if (originalMethodDecl.TypeParameterList is not null) - { - descriptor.TypeParameterList = SyntaxFactory.TypeParameterList(); - foreach (var additionalTypeParameter in - ((TypeParameterListSyntax)declarationSyntaxRewriter.Visit(originalMethodDecl.TypeParameterList)).Parameters) - { - descriptor.TypeParameterList = descriptor.TypeParameterList.AddParameters(additionalTypeParameter); - } - } - - if (originalMethodDecl.ConstraintClauses.Any()) - { - descriptor.ConstraintClauses = SyntaxFactory.List( - originalMethodDecl.ConstraintClauses - .Select(x => (TypeParameterConstraintClauseSyntax)declarationSyntaxRewriter.Visit(x))); - } + ApplyParameterList(originalMethodDecl.ParameterList, declarationSyntaxRewriter, descriptor); + ApplyTypeParameters(originalMethodDecl, declarationSyntaxRewriter, descriptor); return true; } @@ -192,42 +121,14 @@ private static bool TryApplyExpressionPropertyBodyForProperty( SourceProductionContext context, ProjectableDescriptor descriptor) { - ExpressionSyntax? innerBody = null; - string? firstParamName = null; - - if (exprPropDecl.ExpressionBody?.Expression is { } exprBodyExpr) - { - // Expression-bodied property: Prop => @this => … OR Prop => storedExpr - (innerBody, firstParamName) = TryExtractLambdaBodyAndFirstParam(exprBodyExpr, semanticModel, member.SyntaxTree); - } - else if (exprPropDecl.AccessorList is not null) - { - var getter = exprPropDecl.AccessorList.Accessors - .FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); - - if (getter?.ExpressionBody?.Expression is { } getterExprBody) - { - // get => @this => … OR get => storedExpr - (innerBody, firstParamName) = TryExtractLambdaBodyAndFirstParam(getterExprBody, semanticModel, member.SyntaxTree); - } - else if (getter?.Body is not null) - { - // Block-bodied getter: get { return @this => …; } or get { return storedExpr; } - var returnStmt = getter.Body.Statements - .OfType() - .FirstOrDefault(); - - (innerBody, firstParamName) = TryExtractLambdaBodyAndFirstParam(returnStmt?.Expression, semanticModel, member.SyntaxTree); - } - } + var rawExpr = TryGetPropertyGetterExpression(exprPropDecl); + var (innerBody, firstParamName) = rawExpr is not null + ? TryExtractLambdaBodyAndFirstParam(rawExpr, semanticModel, member.SyntaxTree) + : (null, null); if (innerBody is null) { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.RequiresBodyDefinition, - exprPropDecl.GetLocation(), - memberSymbol.Name)); - return false; + return ReportRequiresBodyAndFail(context, exprPropDecl, memberSymbol.Name); } // The generated lambda always uses @this as the receiver parameter name. @@ -304,11 +205,7 @@ private static bool TryApplyPropertyBody( if (bodyExpression is null) { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.RequiresBodyDefinition, - propertyDeclarationSyntax.GetLocation(), - memberSymbol.Name)); - return false; + return ReportRequiresBodyAndFail(context, propertyDeclarationSyntax, memberSymbol.Name); } var returnType = declarationSyntaxRewriter.Visit(propertyDeclarationSyntax.Type); @@ -349,11 +246,7 @@ private static bool TryApplyConstructorBody( descriptor.ReturnTypeName = fullTypeName; // Add the constructor's own parameters to the lambda parameter list - foreach (var additionalParameter in - ((ParameterListSyntax)declarationSyntaxRewriter.Visit(constructorDeclarationSyntax.ParameterList)).Parameters) - { - descriptor.ParametersList = descriptor.ParametersList!.AddParameters(additionalParameter); - } + ApplyParameterList(constructorDeclarationSyntax.ParameterList, declarationSyntaxRewriter, descriptor); // Accumulated property-name → expression map (later converted to member-init) var accumulatedAssignments = new Dictionary(); @@ -407,11 +300,7 @@ private static bool TryApplyConstructorBody( if (accumulatedAssignments.Count == 0) { - context.ReportDiagnostic(Diagnostic.Create( - Diagnostics.RequiresBodyDefinition, - constructorDeclarationSyntax.GetLocation(), - memberSymbol.Name)); - return false; + return ReportRequiresBodyAndFail(context, constructorDeclarationSyntax, memberSymbol.Name); } // Verify the containing type has an accessible parameterless (instance) constructor. @@ -530,40 +419,16 @@ private static (ExpressionSyntax? body, string? firstParamName) TryExtractLambda // Property: unwrap its body the same way we unwrap the outer property. if (declSyntax is PropertyDeclarationSyntax followedProp) { - if (followedProp.ExpressionBody?.Expression is { } followedExprBody) - { - var result = TryExtractLambdaBodyAndFirstParam(followedExprBody, semanticModel, memberSyntaxTree, depth + 1); - if (result.body is not null) - { - return result; - } - } - - var followedGetter = followedProp.AccessorList?.Accessors - .FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); - - if (followedGetter?.ExpressionBody?.Expression is { } getterExprBody) - { - var result = TryExtractLambdaBodyAndFirstParam(getterExprBody, semanticModel, memberSyntaxTree, depth + 1); - if (result.body is not null) - { - return result; - } - } - - var returnResult = TryExtractLambdaBodyAndFirstParam( - followedGetter?.Body?.Statements - .OfType() - .FirstOrDefault()?.Expression, - semanticModel, memberSyntaxTree, depth + 1); - - if (returnResult.body is not null) + var result = TryExtractLambdaBodyAndFirstParam( + TryGetPropertyGetterExpression(followedProp), semanticModel, memberSyntaxTree, depth + 1); + if (result.body is not null) { - return returnResult; + return result; } } } + return (null, null); } } \ No newline at end of file diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.Helpers.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.Helpers.cs new file mode 100644 index 0000000..624c9e1 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.Helpers.cs @@ -0,0 +1,98 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace EntityFrameworkCore.Projectables.Generator; + +public static partial class ProjectableInterpreter +{ + /// + /// Visits through and appends + /// all resulting parameters to . + /// + private static void ApplyParameterList( + ParameterListSyntax parameterList, + DeclarationSyntaxRewriter rewriter, + ProjectableDescriptor descriptor) + { + foreach (var p in ((ParameterListSyntax)rewriter.Visit(parameterList)).Parameters) + { + descriptor.ParametersList = descriptor.ParametersList!.AddParameters(p); + } + } + + /// + /// Visits the type-parameter list and constraint clauses of + /// through and stores them on . + /// + private static void ApplyTypeParameters( + MethodDeclarationSyntax methodDecl, + DeclarationSyntaxRewriter rewriter, + ProjectableDescriptor descriptor) + { + if (methodDecl.TypeParameterList is not null) + { + descriptor.TypeParameterList = SyntaxFactory.TypeParameterList(); + foreach (var tp in ((TypeParameterListSyntax)rewriter.Visit(methodDecl.TypeParameterList)).Parameters) + { + descriptor.TypeParameterList = descriptor.TypeParameterList.AddParameters(tp); + } + } + + if (methodDecl.ConstraintClauses.Any()) + { + descriptor.ConstraintClauses = SyntaxFactory.List( + methodDecl.ConstraintClauses + .Select(x => (TypeParameterConstraintClauseSyntax)rewriter.Visit(x))); + } + } + + /// + /// Returns the readable getter expression from a property declaration, trying in order: + /// the property-level expression-body, the getter's expression-body, then the first + /// expression in a block-bodied getter. + /// Returns null when none of these are present. + /// + private static ExpressionSyntax? TryGetPropertyGetterExpression(PropertyDeclarationSyntax prop) + { + if (prop.ExpressionBody?.Expression is { } exprBody) + { + return exprBody; + } + + if (prop.AccessorList is not null) + { + var getter = prop.AccessorList.Accessors + .FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); + + if (getter?.ExpressionBody?.Expression is { } getterExpr) + { + return getterExpr; + } + + if (getter?.Body?.Statements.OfType().FirstOrDefault()?.Expression is { } returnExpr) + { + return returnExpr; + } + } + + return null; + } + + /// + /// Reports for + /// and returns false so callers can write return ReportRequiresBodyAndFail(…). + /// + private static bool ReportRequiresBodyAndFail( + SourceProductionContext context, + SyntaxNode node, + string memberName) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.RequiresBodyDefinition, + node.GetLocation(), + memberName)); + return false; + } +} + diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs index bd30d18..7bbc0e6 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs @@ -235,25 +235,17 @@ x is IPropertySymbol xProperty && } /// Returns true when a has a readable body. - private static bool HasReadablePropertyBody(PropertyDeclarationSyntax xProp) + private static bool HasReadablePropertyBody(PropertyDeclarationSyntax prop) { - if (xProp.ExpressionBody is not null) + if (prop.ExpressionBody is not null) { return true; } - if (xProp.AccessorList is not null) - { - var getter = xProp.AccessorList.Accessors - .FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); - - if (getter?.ExpressionBody is not null || getter?.Body is not null) - { - return true; - } - } - - return false; + var getter = prop.AccessorList?.Accessors + .FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); + + return getter?.ExpressionBody is not null || getter?.Body is not null; } /// Returns true when a symbol is a property returning Expression<TDelegate>. From 8c10eaf734b98e051f798b41c4741fb4d4118f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20M=C3=A9nager?= Date: Mon, 9 Mar 2026 23:06:26 +0100 Subject: [PATCH 3/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ProjectableInterpreter.BodyProcessors.cs | 4 ++-- .../ProjectableInterpreter.MemberBodyResolver.cs | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs index 3e2bb41..72f47bb 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs @@ -376,13 +376,13 @@ private static (ExpressionSyntax? body, string? firstParamName) TryExtractLambda // Block-bodied lambda yields null body → falls through to EFP0006. if (expression is SimpleLambdaExpressionSyntax simpleLambda) { - return (simpleLambda.Body as ExpressionSyntax, simpleLambda.Parameter.Identifier.Text); + return (simpleLambda.Body as ExpressionSyntax, simpleLambda.Parameter.Identifier.ValueText); } if (expression is ParenthesizedLambdaExpressionSyntax parenLambda) { var firstName = parenLambda.ParameterList.Parameters.Count > 0 - ? parenLambda.ParameterList.Parameters[0].Identifier.Text + ? parenLambda.ParameterList.Parameters[0].Identifier.ValueText : null; return (parenLambda.Body as ExpressionSyntax, firstName); } diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs index 7bbc0e6..a6be6ba 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs @@ -152,6 +152,16 @@ x is IPropertySymbol xProperty && return false; } + // For instance properties, the first delegate type argument must be the containing type + // (the implicit receiver). + if (!exprCheckProperty.IsStatic) + { + var receiverType = delegateType.TypeArguments[0]; + if (!comparer.Equals(receiverType, exprCheckProperty.ContainingType)) + { + return false; + } + } // Return-type check: the last type argument of the delegate must match the property type. var delegateReturnType = delegateType.TypeArguments[delegateType.TypeArguments.Length - 1]; return comparer.Equals(delegateReturnType, exprCheckProperty.Type); From 23314696d8ac82b611a28b6a7bd6a6f50d0445f3 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Mon, 9 Mar 2026 23:06:48 +0100 Subject: [PATCH 4/7] Remove duplicated code --- .../Services/ProjectableExpressionReplacer.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index 346063d..e342a6e 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -316,12 +316,6 @@ private Expression _AddProjectableSelect(Expression node, IEntityType entityType var properties = entityType.GetProperties() .Where(x => !x.IsShadowProperty()) .Select(x => x.GetMemberInfo(false, false)) - .Concat(entityType.GetNavigations() - .Where(x => !x.IsShadowProperty()) - .Select(x => x.GetMemberInfo(false, false))) - .Concat(entityType.GetSkipNavigations() - .Where(x => !x.IsShadowProperty()) - .Select(x => x.GetMemberInfo(false, false))) .Concat(entityType.GetNavigations() .Where(x => !x.IsShadowProperty()) .Select(x => x.GetMemberInfo(false, false))) From c6d24173b3b3e23da6cbea592b167ac68deecf4c Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Mon, 9 Mar 2026 23:09:49 +0100 Subject: [PATCH 5/7] Enhance receiver type determination for implicit 'this' in member body resolution --- ...ojectableInterpreter.MemberBodyResolver.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs index a6be6ba..2622726 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs @@ -81,6 +81,17 @@ x is IPropertySymbol xProperty && var hasImplicitThis = !exprCheckMethod.IsStatic || isExtensionBlock; var expectedFuncArgCount = exprCheckMethod.Parameters.Length + (hasImplicitThis ? 2 : 1); + // Determine the expected receiver type when hasImplicitThis is true. + // For extension-block members the receiver is the extension parameter's type; + // for ordinary instance methods it is the containing type. + ITypeSymbol? expectedReceiverType = null; + if (hasImplicitThis) + { + expectedReceiverType = isExtensionBlock + ? exprCheckMethod.ContainingType.ExtensionParameter?.Type + : exprCheckMethod.ContainingType; + } + compatibleExprPropertyCandidates = exprPropertyCandidates.Where(x => { if (x is not IPropertySymbol propSym) @@ -103,6 +114,17 @@ x is IPropertySymbol xProperty && return false; } + // Receiver-type check: when hasImplicitThis is true, TypeArguments[0] must match + // the implicit receiver — the containing type for instance methods, or the extension + // receiver type for extension-block members. + if (hasImplicitThis && expectedReceiverType is not null) + { + if (!comparer.Equals(delegateType.TypeArguments[0], expectedReceiverType)) + { + return false; + } + } + // Return-type check: the last type argument of the delegate must match the method's return type. var delegateReturnType = delegateType.TypeArguments[delegateType.TypeArguments.Length - 1]; if (!comparer.Equals(delegateReturnType, exprCheckMethod.ReturnType)) From d13ade2d4eeda03295e4a68c6aa78c354ecf3a3a Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Tue, 10 Mar 2026 11:01:04 +0100 Subject: [PATCH 6/7] Fix parameter name overriden by error --- .../ProjectableInterpreter.BodyProcessors.cs | 28 +++- .../VariableReplacementRewriter.cs | 25 ++++ ...LambdaCapturesRenamedReceiver.verified.txt | 20 +++ ...dowingReceiverName_NotRenamed.verified.txt | 20 +++ ...d_WithAlternativeReceiverName.verified.txt | 18 +++ ...amsAndAlternativeReceiverName.verified.txt | 18 +++ .../UseMemberBodyTests.cs | 131 ++++++++++++++++++ 7 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_NestedLambdaCapturesRenamedReceiver.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_NestedLambdaShadowingReceiverName_NotRenamed.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithAlternativeReceiverName.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithParamsAndAlternativeReceiverName.verified.txt diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs index 72f47bb..ab895fe 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs @@ -83,9 +83,9 @@ private static bool TryApplyExpressionPropertyBody( ProjectableDescriptor descriptor) { var rawExpr = TryGetPropertyGetterExpression(exprPropDecl); - var innerBody = rawExpr is not null - ? TryExtractLambdaBody(rawExpr, semanticModel, member.SyntaxTree) - : null; + var (innerBody, firstParamName) = rawExpr is not null + ? TryExtractLambdaBodyAndFirstParam(rawExpr, semanticModel, member.SyntaxTree) + : (null, null); if (innerBody is null) { @@ -94,7 +94,27 @@ private static bool TryApplyExpressionPropertyBody( var returnType = declarationSyntaxRewriter.Visit(originalMethodDecl.ReturnType); descriptor.ReturnTypeName = returnType.ToString(); - descriptor.ExpressionBody = (ExpressionSyntax)expressionSyntaxRewriter.Visit(innerBody); + + // expressionSyntaxRewriter uses the semantic model which requires the original + // (pre-rename) syntax nodes, so we must visit before renaming. + var visitedBody = (ExpressionSyntax)expressionSyntaxRewriter.Visit(innerBody); + + // For instance methods and C#14 extension members, BuildBaseDescriptor adds an + // implicit @this receiver parameter. If the expression property lambda uses a + // different parameter name (e.g. c => c.Value > 0), rename it so the generated + // code references @this instead of an undefined identifier. + var isExtensionMember = memberSymbol.ContainingType is { IsExtension: true }; + var hasImplicitReceiver = isExtensionMember + || !originalMethodDecl.Modifiers.Any(SyntaxKind.StaticKeyword); + + if (hasImplicitReceiver && firstParamName is not null && firstParamName != "@this") + { + visitedBody = (ExpressionSyntax)new VariableReplacementRewriter( + firstParamName, + SyntaxFactory.IdentifierName("@this")).Visit(visitedBody); + } + + descriptor.ExpressionBody = visitedBody; ApplyParameterList(originalMethodDecl.ParameterList, declarationSyntaxRewriter, descriptor); ApplyTypeParameters(originalMethodDecl, declarationSyntaxRewriter, descriptor); diff --git a/src/EntityFrameworkCore.Projectables.Generator/VariableReplacementRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/VariableReplacementRewriter.cs index 578aa74..6fd8d92 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/VariableReplacementRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/VariableReplacementRewriter.cs @@ -38,4 +38,29 @@ public VariableReplacementRewriter(string variableName, ExpressionSyntax replace return base.VisitMemberAccessExpression(node); } + + // ── Scope tracking ──────────────────────────────────────────────────────── + // When a nested lambda re-declares the same parameter name, it shadows the + // outer variable we are renaming. Stop descending so that identifiers in + // that nested lambda body refer to the inner parameter, not to @this. + + public override SyntaxNode? VisitSimpleLambdaExpression(SimpleLambdaExpressionSyntax node) + { + if (node.Parameter.Identifier.ValueText == _variableName) + { + return node; // inner parameter shadows – leave entire sub-tree untouched + } + + return base.VisitSimpleLambdaExpression(node); + } + + public override SyntaxNode? VisitParenthesizedLambdaExpression(ParenthesizedLambdaExpressionSyntax node) + { + if (node.ParameterList.Parameters.Any(p => p.Identifier.ValueText == _variableName)) + { + return node; // inner parameter shadows – leave entire sub-tree untouched + } + + return base.VisitParenthesizedLambdaExpression(node); + } } diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_NestedLambdaCapturesRenamedReceiver.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_NestedLambdaCapturesRenamedReceiver.verified.txt new file mode 100644 index 0000000..e1f43be --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_NestedLambdaCapturesRenamedReceiver.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_ScaledItems + { + static global::System.Linq.Expressions.Expression>> Expression() + { + return (global::Foo.C @this) => global::System.Linq.Enumerable.Select(@this.Items, item => item * @this.Multiplier); + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_NestedLambdaShadowingReceiverName_NotRenamed.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_NestedLambdaShadowingReceiverName_NotRenamed.verified.txt new file mode 100644 index 0000000..f3f83d9 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_NestedLambdaShadowingReceiverName_NotRenamed.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_DoubledItems + { + static global::System.Linq.Expressions.Expression>> Expression() + { + return (global::Foo.C @this) => global::System.Linq.Enumerable.Select(@this.Items, c => c * 2); + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithAlternativeReceiverName.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithAlternativeReceiverName.verified.txt new file mode 100644 index 0000000..e7f84a8 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithAlternativeReceiverName.verified.txt @@ -0,0 +1,18 @@ +// +#nullable disable +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_IsPositive + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => @this.Value > 0; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithParamsAndAlternativeReceiverName.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithParamsAndAlternativeReceiverName.verified.txt new file mode 100644 index 0000000..5dd82c7 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithParamsAndAlternativeReceiverName.verified.txt @@ -0,0 +1,18 @@ +// +#nullable disable +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_ExceedsThreshold_P0_int + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this, int threshold) => @this.Value > threshold; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs index 3711367..138d9d7 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs @@ -615,6 +615,137 @@ static class EntityExtensions { Assert.Empty(result.GeneratedTrees); } + [Fact] + public Task Method_UsesExpressionPropertyBody_InstanceMethod_WithAlternativeReceiverName() + { + // Regression test: when the Expression property lambda uses a different + // parameter name for the receiver (e.g. 'c' instead of '@this'), the generated + // code must still reference @this — not the original lambda parameter name which + // would be undefined in the generated lambda. + var compilation = CreateCompilation(@" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + public int Value { get; set; } + + [Projectable(UseMemberBody = nameof(IsPositiveExpr))] + public bool IsPositive() => Value > 0; + + // Uses 'c' instead of '@this' as the lambda receiver parameter name + private static Expression> IsPositiveExpr => c => c.Value > 0; + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task Method_UsesExpressionPropertyBody_InstanceMethod_WithParamsAndAlternativeReceiverName() + { + // Same regression: method with extra parameters, receiver lambda param renamed to @this, + // remaining explicit parameters keep their names from the method declaration. + var compilation = CreateCompilation(@" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + public int Value { get; set; } + + [Projectable(UseMemberBody = nameof(ExceedsThresholdExpr))] + public bool ExceedsThreshold(int threshold) => Value > threshold; + + // Uses 'entity' instead of '@this' as the receiver parameter name + private static Expression> ExceedsThresholdExpr => + (entity, threshold) => entity.Value > threshold; + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task Method_UsesExpressionPropertyBody_InstanceMethod_NestedLambdaShadowingReceiverName_NotRenamed() + { + // Regression: the scope-unaware VariableReplacementRewriter used to rename every + // occurrence of the outer lambda's receiver parameter — including identifiers in + // nested lambdas that re-declare the same name (shadowing). + // Only the outer receiver parameter should be renamed to @this; the inner lambda's + // parameter with the same name must remain unchanged. + var compilation = CreateCompilation(@" +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + public IEnumerable Items { get; set; } + + [Projectable(UseMemberBody = nameof(DoubledItemsExpr))] + public IEnumerable DoubledItems() => Items.Select(x => x * 2); + + // Outer parameter is named 'c'; nested lambda also uses 'c' (shadows outer). + private static Expression>> DoubledItemsExpr => + c => c.Items.Select(c => c * 2); + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task Method_UsesExpressionPropertyBody_InstanceMethod_NestedLambdaCapturesRenamedReceiver() + { + // Complement to the shadowing test: when the nested lambda does NOT reuse the outer + // parameter name but captures the outer receiver, references to the outer variable + // inside the inner lambda body MUST still be renamed to @this. + var compilation = CreateCompilation(@" +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + public IEnumerable Items { get; set; } + public int Multiplier { get; set; } + + [Projectable(UseMemberBody = nameof(ScaledItemsExpr))] + public IEnumerable ScaledItems() => Items.Select(x => x * Multiplier); + + // Nested lambda uses 'item' (different from outer 'c'), but references the outer 'c' + // inside its body via c.Multiplier — that reference must be renamed to @this. + private static Expression>> ScaledItemsExpr => + c => c.Items.Select(item => item * c.Multiplier); + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + [Fact] public void UseMemberBody_InstanceMethod_ExpressionProperty_IncompatibleReturnType_EmitsEFP0011() { From 6f81bdd7c08e815f99442a017bb4430b6449e549 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Tue, 10 Mar 2026 14:00:17 +0100 Subject: [PATCH 7/7] Fix parameter names --- README.md | 34 ++++- .../ProjectableInterpreter.BodyProcessors.cs | 117 ++++++++++++++---- ...ojectableInterpreter.MemberBodyResolver.cs | 5 +- .../ProjectableInterpreter.cs | 25 +++- .../ProjectionExpressionGeneratorTestsBase.cs | 36 ++++++ ...essionPropertyInDifferentFile.verified.txt | 18 +++ ...thDifferentExplicitParamNames.verified.txt | 18 +++ ...essionPropertyInDifferentFile.verified.txt | 18 +++ .../UseMemberBodyTests.cs | 104 ++++++++++++++++ 9 files changed, 348 insertions(+), 27 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithDifferentExplicitParamNames.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt diff --git a/README.md b/README.md index 60fa132..8a0accc 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ Multiple `[Projectable]` constructors (overloads) per class are fully supported. #### Can I redirect the expression body to a different member with `UseMemberBody`? -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). +Yes! The `UseMemberBody` property on `[Projectable]` lets you redirect the source of the generated expression to a *different* member on the same type. This is useful when you want to: @@ -343,6 +343,8 @@ public class Entity The generated expression is `(@this) => @this.Id * 2`, so `Computed` projects as `Id * 2` in SQL even though the arrow body says `Id`. +> **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. + ##### Using an `Expression>` property as the body For even more control you can supply the body as a typed `Expression>` 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 } ``` -> **Note:** When the projectable member is a *property*, the `Expression>` property body is handled entirely by the runtime resolver — no extra source is generated. This works transparently. +Unlike regular method/property delegation, `Expression>` backing properties may be declared in a **different file** — for example in a separate part of a `partial class`: + +```csharp +// File: Entity.cs +public partial class Entity +{ + public int Id { get; set; } -For **instance methods**, name the lambda parameter `@this` so that it matches the generator's own naming convention: + [Projectable(UseMemberBody = nameof(IdDoubledExpr))] + public int Computed => Id; +} + +// File: Entity.Expressions.cs +public partial class Entity +{ + private static Expression> IdDoubledExpr => @this => @this.Id * 2; +} +``` + +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: ```csharp public class Entity @@ -372,10 +391,19 @@ public class Entity [Projectable(UseMemberBody = nameof(IsPositiveExpr))] public bool IsPositive() => Value > 0; + // Any receiver name works; @this is conventional private static Expression> IsPositiveExpr => @this => @this.Value > 0; } ``` +If the lambda parameter names differ from the method's parameter names the generator renames them automatically: + +```csharp +// Lambda uses (c, t) but method parameter is named threshold — generated code uses threshold +private static Expression> ExceedsThresholdExpr => + (c, t) => c.Value > t; +``` + ##### Static extension methods `UseMemberBody` works equally well on static extension methods. Name the lambda parameters to match the method's parameter names: diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs index ab895fe..9d07385 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.BodyProcessors.cs @@ -83,9 +83,9 @@ private static bool TryApplyExpressionPropertyBody( ProjectableDescriptor descriptor) { var rawExpr = TryGetPropertyGetterExpression(exprPropDecl); - var (innerBody, firstParamName) = rawExpr is not null - ? TryExtractLambdaBodyAndFirstParam(rawExpr, semanticModel, member.SyntaxTree) - : (null, null); + var (innerBody, lambdaParamNames) = rawExpr is not null + ? TryExtractLambdaBodyAndParams(rawExpr, semanticModel, member.SyntaxTree) + : (null, []); if (innerBody is null) { @@ -97,7 +97,12 @@ private static bool TryApplyExpressionPropertyBody( // expressionSyntaxRewriter uses the semantic model which requires the original // (pre-rename) syntax nodes, so we must visit before renaming. - var visitedBody = (ExpressionSyntax)expressionSyntaxRewriter.Visit(innerBody); + // For cross-tree expression properties the rewriter's SemanticModel cannot resolve + // nodes from the other file — skip rewriting in that case (simple lambda bodies need + // no rewrites; advanced features like null-conditional rewriting are unsupported cross-file). + var visitedBody = exprPropDecl.SyntaxTree == member.SyntaxTree + ? (ExpressionSyntax)expressionSyntaxRewriter.Visit(innerBody) + : innerBody; // For instance methods and C#14 extension members, BuildBaseDescriptor adds an // implicit @this receiver parameter. If the expression property lambda uses a @@ -107,11 +112,62 @@ private static bool TryApplyExpressionPropertyBody( var hasImplicitReceiver = isExtensionMember || !originalMethodDecl.Modifiers.Any(SyntaxKind.StaticKeyword); - if (hasImplicitReceiver && firstParamName is not null && firstParamName != "@this") + // Collect (lambdaParamName → methodParamName) rename pairs to apply in a + // single multi-variable pass, avoiding cascading renames when names overlap. + var renames = new List<(string From, string To)>(); + + var lambdaOffset = 0; + if (hasImplicitReceiver) { - visitedBody = (ExpressionSyntax)new VariableReplacementRewriter( - firstParamName, - SyntaxFactory.IdentifierName("@this")).Visit(visitedBody); + if (lambdaParamNames.Count > 0 && lambdaParamNames[0] != "@this") + { + renames.Add((lambdaParamNames[0], "@this")); + } + + lambdaOffset = 1; + } + + // Rename each explicit method parameter from its lambda counterpart name. + var methodParams = originalMethodDecl.ParameterList.Parameters; + for (var i = 0; i < methodParams.Count; i++) + { + var lambdaIdx = lambdaOffset + i; + if (lambdaIdx >= lambdaParamNames.Count) + { + break; + } + + var lambdaName = lambdaParamNames[lambdaIdx]; + var methodName = methodParams[i].Identifier.ValueText; + if (lambdaName != methodName) + { + renames.Add((lambdaName, methodName)); + } + } + + // Apply all renames. To avoid cascading substitutions when names overlap + // (e.g. swapped parameter names), use a unique sentinel prefix for each + // intermediate name, then replace sentinels with the final names. + if (renames.Count > 0) + { + // Phase 1: rename each source name to a collision-free sentinel. + var sentinels = new List<(string Sentinel, string To)>(renames.Count); + for (var i = 0; i < renames.Count; i++) + { + var sentinel = $"__rename_sentinel_{i}__"; + visitedBody = (ExpressionSyntax)new VariableReplacementRewriter( + renames[i].From, + SyntaxFactory.IdentifierName(sentinel)).Visit(visitedBody); + sentinels.Add((sentinel, renames[i].To)); + } + + // Phase 2: replace each sentinel with the final target name. + foreach (var (sentinel, to) in sentinels) + { + visitedBody = (ExpressionSyntax)new VariableReplacementRewriter( + sentinel, + SyntaxFactory.IdentifierName(to)).Visit(visitedBody); + } } descriptor.ExpressionBody = visitedBody; @@ -155,7 +211,11 @@ private static bool TryApplyExpressionPropertyBodyForProperty( // If the expression property used a different name (e.g. `x => x.Id`), rename it. // NOTE: renaming must happen AFTER expressionSyntaxRewriter.Visit because the rewriter // uses the semantic model which requires the original (pre-rename) syntax nodes. - var visitedBody = (ExpressionSyntax)expressionSyntaxRewriter.Visit(innerBody); + // For cross-tree expression properties the rewriter's SemanticModel cannot resolve + // nodes from the other file — skip rewriting in that case. + var visitedBody = exprPropDecl.SyntaxTree == member.SyntaxTree + ? (ExpressionSyntax)expressionSyntaxRewriter.Visit(innerBody) + : innerBody; if (firstParamName is not null && firstParamName != "@this") { visitedBody = (ExpressionSyntax)new VariableReplacementRewriter( @@ -375,7 +435,7 @@ or Accessibility.Internal SemanticModel semanticModel, SyntaxTree memberSyntaxTree, int depth = 0) - => TryExtractLambdaBodyAndFirstParam(expression, semanticModel, memberSyntaxTree, depth).body; + => TryExtractLambdaBodyAndParams(expression, semanticModel, memberSyntaxTree, depth).body; /// /// Like , but also returns the first lambda parameter name @@ -386,25 +446,39 @@ private static (ExpressionSyntax? body, string? firstParamName) TryExtractLambda SemanticModel semanticModel, SyntaxTree memberSyntaxTree, int depth = 0) + { + var (body, paramNames) = TryExtractLambdaBodyAndParams(expression, semanticModel, memberSyntaxTree, depth); + return (body, paramNames.Count > 0 ? paramNames[0] : null); + } + + /// + /// Like , but also returns all lambda parameter names + /// (in declaration order) so callers can rename them in the extracted body. + /// + private static (ExpressionSyntax? body, IReadOnlyList paramNames) TryExtractLambdaBodyAndParams( + ExpressionSyntax? expression, + SemanticModel semanticModel, + SyntaxTree memberSyntaxTree, + int depth = 0) { if (expression is null || depth > 5) { - return (null, null); + return (null, []); } - // Lambda literal → extract its expression body and first parameter name directly. + // Lambda literal → extract its expression body and parameter names directly. // Block-bodied lambda yields null body → falls through to EFP0006. if (expression is SimpleLambdaExpressionSyntax simpleLambda) { - return (simpleLambda.Body as ExpressionSyntax, simpleLambda.Parameter.Identifier.ValueText); + return (simpleLambda.Body as ExpressionSyntax, [simpleLambda.Parameter.Identifier.ValueText]); } if (expression is ParenthesizedLambdaExpressionSyntax parenLambda) { - var firstName = parenLambda.ParameterList.Parameters.Count > 0 - ? parenLambda.ParameterList.Parameters[0].Identifier.ValueText - : null; - return (parenLambda.Body as ExpressionSyntax, firstName); + var names = parenLambda.ParameterList.Parameters + .Select(p => p.Identifier.ValueText) + .ToList(); + return (parenLambda.Body as ExpressionSyntax, names); } // Non-lambda: resolve the symbol and follow the reference to its source @@ -412,7 +486,7 @@ private static (ExpressionSyntax? body, string? firstParamName) TryExtractLambda var symbol = semanticModel.GetSymbolInfo(expression).Symbol; if (symbol is not (IFieldSymbol or IPropertySymbol)) { - return (null, null); + return (null, []); } foreach (var syntaxRef in symbol.DeclaringSyntaxReferences) @@ -429,7 +503,7 @@ private static (ExpressionSyntax? body, string? firstParamName) TryExtractLambda // Field: private static readonly Expression> _f = @this => …; if (declSyntax is VariableDeclaratorSyntax { Initializer.Value: var initValue }) { - var result = TryExtractLambdaBodyAndFirstParam(initValue, semanticModel, memberSyntaxTree, depth + 1); + var result = TryExtractLambdaBodyAndParams(initValue, semanticModel, memberSyntaxTree, depth + 1); if (result.body is not null) { return result; @@ -439,7 +513,7 @@ private static (ExpressionSyntax? body, string? firstParamName) TryExtractLambda // Property: unwrap its body the same way we unwrap the outer property. if (declSyntax is PropertyDeclarationSyntax followedProp) { - var result = TryExtractLambdaBodyAndFirstParam( + var result = TryExtractLambdaBodyAndParams( TryGetPropertyGetterExpression(followedProp), semanticModel, memberSyntaxTree, depth + 1); if (result.body is not null) { @@ -448,7 +522,6 @@ private static (ExpressionSyntax? body, string? firstParamName) TryExtractLambda } } - - return (null, null); + return (null, []); } } \ No newline at end of file diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs index 2622726..ce2da70 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.MemberBodyResolver.cs @@ -234,6 +234,9 @@ x is IPropertySymbol xProperty && // These don't need to share the member's static modifier because a // static Expression> property can legitimately back either // a static or an instance projectable method. + // They are also allowed to live in a different file (e.g. a split partial class): + // a direct lambda body can be extracted purely syntactically without a shared + // SemanticModel; the runtime fallback handles any cases the generator can't inline. if (resolvedBody is null && compatibleExprPropertyCandidates.Count > 0) { resolvedBody = compatibleExprPropertyCandidates @@ -242,7 +245,7 @@ x is IPropertySymbol xProperty && .OfType() .FirstOrDefault(x => { - if (x == null || x.SyntaxTree != member.SyntaxTree) + if (x == null) { return false; } diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index 47f4c60..1486cc3 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -74,7 +74,7 @@ public static partial class ProjectableInterpreter // Projectable property whose body is an Expression property (PropertyDeclarationSyntax originalPropertyDecl, PropertyDeclarationSyntax exprPropDecl) - when semanticModel.GetDeclaredSymbol(exprPropDecl) is IPropertySymbol s && IsExpressionDelegateProperty(s) => + when IsExpressionDelegatePropertyDecl(exprPropDecl, semanticModel) => TryApplyExpressionPropertyBodyForProperty(originalPropertyDecl, exprPropDecl, semanticModel, member, memberSymbol, expressionSyntaxRewriter, declarationSyntaxRewriter, context, descriptor), @@ -287,4 +287,27 @@ private static void SetupGenericTypeParameters(ProjectableDescriptor descriptor, SyntaxFactory.SeparatedList(constraints))); } } + + /// + /// Returns true when is a property that returns + /// Expression<TDelegate>. + /// + /// For nodes in the same as the , + /// a full semantic check is performed. For cross-tree nodes (e.g., a backing property + /// declared in a different file of a split partial class), a syntactic name check is used + /// as a safe fallback — has already validated + /// compatibility semantically before handing us the node. + /// + /// + private static bool IsExpressionDelegatePropertyDecl(PropertyDeclarationSyntax prop, SemanticModel semanticModel) + { + if (prop.SyntaxTree == semanticModel.SyntaxTree) + { + return semanticModel.GetDeclaredSymbol(prop) is IPropertySymbol s && IsExpressionDelegateProperty(s); + } + + // Cross-tree: syntactic name check (type is Expression<...> or qualified variant). + return prop.Type is GenericNameSyntax { Identifier.ValueText: "Expression" } + || prop.Type is QualifiedNameSyntax { Right: GenericNameSyntax { Identifier.ValueText: "Expression" } }; + } } \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTestsBase.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTestsBase.cs index 6508c2b..431c726 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTestsBase.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTestsBase.cs @@ -75,6 +75,42 @@ protected IReadOnlyList GetDefaultReferences() return references; } + /// + /// Creates a test compilation from multiple source strings (e.g., to simulate split partial classes + /// across different files). Each element in becomes a separate + /// so that cross-tree resolution paths are exercised. + /// + protected Compilation CreateCompilation(params string[] sources) + { + var references = GetDefaultReferences(); + + var compilation = CSharpCompilation.Create("compilation", + sources.Select(s => CSharpSyntaxTree.ParseText(s)).ToArray(), + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + +#if DEBUG + var compilationDiagnostics = compilation.GetDiagnostics(); + + if (!compilationDiagnostics.IsEmpty) + { + _testOutputHelper.WriteLine($"Original compilation diagnostics produced:"); + + foreach (var diagnostic in compilationDiagnostics) + { + _testOutputHelper.WriteLine($" > " + diagnostic.ToString()); + } + + if (compilationDiagnostics.Any(x => x.Severity == DiagnosticSeverity.Error)) + { + Debug.Fail("Compilation diagnostics produced"); + } + } +#endif + + return compilation; + } + protected Compilation CreateCompilation([StringSyntax("csharp")] string source) { var references = GetDefaultReferences(); diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt new file mode 100644 index 0000000..e7f84a8 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt @@ -0,0 +1,18 @@ +// +#nullable disable +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_IsPositive + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => @this.Value > 0; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithDifferentExplicitParamNames.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithDifferentExplicitParamNames.verified.txt new file mode 100644 index 0000000..5dd82c7 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Method_UsesExpressionPropertyBody_InstanceMethod_WithDifferentExplicitParamNames.verified.txt @@ -0,0 +1,18 @@ +// +#nullable disable +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_ExceedsThreshold_P0_int + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this, int threshold) => @this.Value > threshold; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt new file mode 100644 index 0000000..34f67c5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.Property_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile.verified.txt @@ -0,0 +1,18 @@ +// +#nullable disable +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_Computed + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => @this.Id * 2; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs index 138d9d7..a907ef8 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UseMemberBodyTests.cs @@ -1,6 +1,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using VerifyXunit; using Xunit; using Xunit.Abstractions; @@ -746,6 +747,109 @@ class C { return Verifier.Verify(result.GeneratedTrees[0].ToString()); } + [Fact] + public Task Method_UsesExpressionPropertyBody_InstanceMethod_WithDifferentExplicitParamNames() + { + // Regression test (Issue 2): when ALL lambda parameter names differ from the method's + // parameter names (not just the receiver), the generated code must rename them all. + // Here 'c' → '@this' and 't' → 'threshold'. + var compilation = CreateCompilation(@" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + public int Value { get; set; } + + [Projectable(UseMemberBody = nameof(ExceedsThresholdExpr))] + public bool ExceedsThreshold(int threshold) => Value > threshold; + + // Uses 'c' for the receiver and 't' for the parameter — both differ from method names + private static Expression> ExceedsThresholdExpr => + (c, t) => c.Value > t; + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task Method_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile() + { + // Regression test (Issue 1): the Expression backing property is declared + // in a different file (separate SyntaxTree) than the [Projectable] method, as is + // typical for split partial classes. Previously this caused EFP0011; now the + // generator should inline the lambda and emit the expression tree. + var compilation = CreateCompilation( + @" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + partial class C { + public int Value { get; set; } + + [Projectable(UseMemberBody = nameof(IsPositiveExpr))] + public bool IsPositive() => Value > 0; + } +}", + @" +using System; +using System.Linq.Expressions; +namespace Foo { + partial class C { + private static Expression> IsPositiveExpr => @this => @this.Value > 0; + } +}"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task Property_UsesExpressionPropertyBody_ExpressionPropertyInDifferentFile() + { + // Regression test (Issue 1): same as the method variant above but for a projectable + // property. The Expression> backing property lives in a second file. + var compilation = CreateCompilation( + @" +using System; +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables; +namespace Foo { + partial class C { + public int Id { get; set; } + + [Projectable(UseMemberBody = nameof(IdDoubledExpr))] + public int Computed => Id; + } +}", + @" +using System; +using System.Linq.Expressions; +namespace Foo { + partial class C { + private static Expression> IdDoubledExpr => @this => @this.Id * 2; + } +}"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + [Fact] public void UseMemberBody_InstanceMethod_ExpressionProperty_IncompatibleReturnType_EmitsEFP0011() {