From 846fc05117b8d3fc36e5713e58bdc3ae1fb21e78 Mon Sep 17 00:00:00 2001 From: Koen Date: Sat, 21 Mar 2026 01:04:15 +0000 Subject: [PATCH] Add support for EFP0013 diagnostic for unsupported expressions in projectable members - Introduced new diagnostic descriptor for unsupported expressions. - Updated DeclarationSyntaxRewriter and ExpressionSyntaxRewriter to report diagnostics for unsupported syntax nodes. - Added tests to ensure correct diagnostics are emitted for various unsupported expressions. --- .../AnalyzerReleases.Unshipped.md | 6 +- .../Infrastructure/Diagnostics.cs | 8 + .../Interpretation/ProjectableInterpreter.cs | 5 +- .../DeclarationSyntaxRewriter.cs | 21 ++- .../ExpressionSyntaxRewriter.cs | 85 ++++++++++- .../UnsupportedExpressionTests.cs | 137 ++++++++++++++++++ 6 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/UnsupportedExpressionTests.cs diff --git a/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Unshipped.md b/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Unshipped.md index 5f28270..84b83e6 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Unshipped.md +++ b/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Unshipped.md @@ -1 +1,5 @@ - \ No newline at end of file +### New Rules + +| Rule ID | Category | Severity | Notes | +|---------|----------|----------|----------------------------------------------| +| EFP0013 | Design | Error | Unsupported expression in projectable member | diff --git a/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs b/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs index 9fab2f2..7cd569d 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs @@ -99,4 +99,12 @@ static internal class Diagnostics category: "Design", DiagnosticSeverity.Info, isEnabledByDefault: true); + + public readonly static DiagnosticDescriptor UnsupportedExpressionInProjectable = new DiagnosticDescriptor( + id: "EFP0013", + title: "Unsupported expression in projectable member", + messageFormat: "The expression '{0}' cannot be used in a projectable member: {1}", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } \ No newline at end of file diff --git a/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs index ba8f327..bee8f1c 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/Interpretation/ProjectableInterpreter.cs @@ -51,8 +51,9 @@ static internal partial class ProjectableInterpreter expandEnumMethods, semanticModel, context, - extensionParameter?.Name); - var declarationSyntaxRewriter = new DeclarationSyntaxRewriter(semanticModel); + extensionParameter?.Name, + owningMethod: memberSymbol as IMethodSymbol); + var declarationSyntaxRewriter = new DeclarationSyntaxRewriter(semanticModel, context); // 4. Build base descriptor (class names, namespaces, @this parameter, target class) var methodSymbol = memberSymbol as IMethodSymbol; diff --git a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/DeclarationSyntaxRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/DeclarationSyntaxRewriter.cs index 454eefa..3d1859c 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/DeclarationSyntaxRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/DeclarationSyntaxRewriter.cs @@ -1,4 +1,5 @@ -using Microsoft.CodeAnalysis; +using EntityFrameworkCore.Projectables.Generator.Infrastructure; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -7,10 +8,12 @@ namespace EntityFrameworkCore.Projectables.Generator.SyntaxRewriters; internal class DeclarationSyntaxRewriter : CSharpSyntaxRewriter { readonly SemanticModel _semanticModel; + readonly SourceProductionContext _context; - public DeclarationSyntaxRewriter(SemanticModel semanticModel) + public DeclarationSyntaxRewriter(SemanticModel semanticModel, SourceProductionContext context) { _semanticModel = semanticModel; + _context = context; } public override SyntaxNode? VisitParameter(ParameterSyntax node) @@ -38,6 +41,20 @@ public DeclarationSyntaxRewriter(SemanticModel semanticModel) { visitedNode = ((ParameterSyntax)visitedNode).WithDefault(null); } + + // Ref-like types (Span, ReadOnlySpan, etc.) cannot be lambda parameters in expression trees. + if (node.Type is { } paramTypeSyntax) + { + var paramTypeInfo = _semanticModel.GetTypeInfo(paramTypeSyntax); + if (paramTypeInfo.Type is INamedTypeSymbol { IsRefLikeType: true } refLikeType) + { + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedExpressionInProjectable, + node.GetLocation(), + refLikeType.ToDisplayString(), + $"Ref-like types cannot be used as parameters in expression trees.")); + } + } } return visitedNode; diff --git a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/ExpressionSyntaxRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/ExpressionSyntaxRewriter.cs index 472af8b..2b00908 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/ExpressionSyntaxRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/ExpressionSyntaxRewriter.cs @@ -1,3 +1,4 @@ +using EntityFrameworkCore.Projectables.Generator.Infrastructure; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -14,8 +15,15 @@ internal partial class ExpressionSyntaxRewriter : CSharpSyntaxRewriter readonly SourceProductionContext _context; readonly Stack<(ExpressionSyntax Expression, ITypeSymbol? Type)> _conditionalAccessExpressionsStack = new(); readonly string? _extensionParameterName; - - public ExpressionSyntaxRewriter(INamedTypeSymbol targetTypeSymbol, NullConditionalRewriteSupport nullConditionalRewriteSupport, bool expandEnumMethods, SemanticModel semanticModel, SourceProductionContext context, string? extensionParameterName = null) + /// + /// The symbol of the member being projected. When set, constructor parameters that belong + /// to this symbol are considered valid (they will become lambda parameters in the generated + /// expression tree). Parameters from any other constructor — e.g. a primary constructor + /// referenced from a property body — are flagged as unsupported. + /// + readonly IMethodSymbol? _owningMethod; + + public ExpressionSyntaxRewriter(INamedTypeSymbol targetTypeSymbol, NullConditionalRewriteSupport nullConditionalRewriteSupport, bool expandEnumMethods, SemanticModel semanticModel, SourceProductionContext context, string? extensionParameterName = null, IMethodSymbol? owningMethod = null) { _targetTypeSymbol = targetTypeSymbol; _nullConditionalRewriteSupport = nullConditionalRewriteSupport; @@ -23,10 +31,24 @@ public ExpressionSyntaxRewriter(INamedTypeSymbol targetTypeSymbol, NullCondition _semanticModel = semanticModel; _context = context; _extensionParameterName = extensionParameterName; + _owningMethod = owningMethod; } public SemanticModel GetSemanticModel() => _semanticModel; + public override SyntaxNode? VisitFieldExpression(FieldExpressionSyntax node) + { + // `field` keyword (C# 14) accesses the compiler-synthesised backing field. + // Expression trees have no way to represent this — report EFP0013. + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedExpressionInProjectable, + node.GetLocation(), + "field", + "The 'field' keyword (C# 14 backing field accessor) is not supported in expression trees.")); + return SyntaxFactory.LiteralExpression(SyntaxKind.DefaultLiteralExpression, + SyntaxFactory.Token(SyntaxKind.DefaultKeyword)); + } + private SyntaxNode? VisitThisBaseExpression(CSharpSyntaxNode node) { // Swap out the use of this and base to @this and keep leading and trailing trivias @@ -139,6 +161,24 @@ public ExpressionSyntaxRewriter(INamedTypeSymbol targetTypeSymbol, NullCondition var identifierSymbol = _semanticModel.GetSymbolInfo(node).Symbol; if (identifierSymbol is not null) { + // Primary constructor parameters are not in scope in the generated static method (C# 12+). + // Exception: parameters that belong to the constructor being projected are valid — they + // become lambda parameters in the generated expression tree. We detect this by checking + // whether identifierSymbol is one of _owningMethod's own parameters (comparing the + // parameter symbol directly avoids symbol-equality pitfalls with the containing method). + if (identifierSymbol is IParameterSymbol { ContainingSymbol: IMethodSymbol { MethodKind: MethodKind.Constructor } } + && !(_owningMethod is not null && _owningMethod.Parameters.Any(p => SymbolEqualityComparer.Default.Equals(p, identifierSymbol)))) + { + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedExpressionInProjectable, + node.GetLocation(), + node.Identifier.Text, + "Primary constructor parameters are not accessible in the generated expression tree. " + + "Capture the value in a property or field instead.")); + return SyntaxFactory.LiteralExpression(SyntaxKind.DefaultLiteralExpression, + SyntaxFactory.Token(SyntaxKind.DefaultKeyword)); + } + var operation = node switch { { Parent: { } parent } when parent.IsKind(SyntaxKind.InvocationExpression) => _semanticModel.GetOperation(node.Parent), _ => _semanticModel.GetOperation(node!) }; @@ -310,4 +350,45 @@ public ExpressionSyntaxRewriter(INamedTypeSymbol targetTypeSymbol, NullCondition return ConvertPatternToExpression(node.Pattern, expression) ?? SyntaxFactory.LiteralExpression(SyntaxKind.FalseLiteralExpression); } + + public override SyntaxNode? VisitCollectionExpression(CollectionExpressionSyntax node) + { + // Collection expressions (C# 12) are not supported in expression trees. + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedExpressionInProjectable, + node.GetLocation(), + node.ToString(), + "Collection expressions are not supported in expression trees.")); + return SyntaxFactory.LiteralExpression(SyntaxKind.DefaultLiteralExpression, + SyntaxFactory.Token(SyntaxKind.DefaultKeyword)); + } + + public override SyntaxNode? VisitPrefixUnaryExpression(PrefixUnaryExpressionSyntax node) + { + if (node.IsKind(SyntaxKind.IndexExpression)) + { + // Index-from-end operator (^n, C# 8+) is not supported in expression trees. + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedExpressionInProjectable, + node.GetLocation(), + node.ToString(), + "The index-from-end operator (^) is not supported in expression trees.")); + return SyntaxFactory.LiteralExpression(SyntaxKind.DefaultLiteralExpression, + SyntaxFactory.Token(SyntaxKind.DefaultKeyword)); + } + + return base.VisitPrefixUnaryExpression(node); + } + + public override SyntaxNode? VisitRangeExpression(RangeExpressionSyntax node) + { + // Range expressions (a..b, C# 8+) are not supported in expression trees. + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedExpressionInProjectable, + node.GetLocation(), + node.ToString(), + "The range operator (..) is not supported in expression trees.")); + return SyntaxFactory.LiteralExpression(SyntaxKind.DefaultLiteralExpression, + SyntaxFactory.Token(SyntaxKind.DefaultKeyword)); + } } diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/UnsupportedExpressionTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UnsupportedExpressionTests.cs new file mode 100644 index 0000000..4b42a1c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/UnsupportedExpressionTests.cs @@ -0,0 +1,137 @@ +using Microsoft.CodeAnalysis; +using Xunit; + +namespace EntityFrameworkCore.Projectables.Generator.Tests; + +/// +/// Tests that C# 12–14 syntax nodes that cannot be represented in expression trees cause the +/// generator to emit a clear EFP0013 diagnostic instead of silently producing broken generated code. +/// +public class UnsupportedExpressionTests : ProjectionExpressionGeneratorTestsBase +{ + public UnsupportedExpressionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { } + + [Fact] + public void CollectionExpression_EmitsDiagnostic() + { + var compilation = CreateCompilation(@" +using System.Collections.Generic; +using EntityFrameworkCore.Projectables; + +class Entity +{ + [Projectable] + public List Ids => [1, 2, 3]; +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0013", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void IndexFromEndOperator_EmitsDiagnostic() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +class Entity +{ + public int[] Items { get; set; } + + [Projectable] + public int Last => Items[^1]; +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0013", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void RangeOperator_EmitsDiagnostic() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +class Entity +{ + public int[] Items { get; set; } + + [Projectable] + public int[] Slice => Items[1..3]; +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0013", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void PrimaryConstructorParameter_EmitsDiagnostic() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +class Entity(int id) +{ + [Projectable] + public int DoubledId => id * 2; +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0013", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + +#if NET9_0_OR_GREATER + [Fact] + public void RefStructParameter_EmitsDiagnostic() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; + +static class Extensions +{ + [Projectable] + public static int Sum(params ReadOnlySpan values) => 0; +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0013", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } +#endif + +#if NET10_0_OR_GREATER + [Fact] + public void FieldKeyword_EmitsDiagnostic() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +class Entity +{ + [Projectable] + public string Name { get => field ?? ""default""; set; } +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0013", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } +#endif +}