Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@

### New Rules

| Rule ID | Category | Severity | Notes |
|---------|----------|----------|----------------------------------------------|
| EFP0013 | Design | Error | Unsupported expression in projectable member |
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.CodeAnalysis;
using EntityFrameworkCore.Projectables.Generator.Infrastructure;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

Expand All @@ -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)
Expand Down Expand Up @@ -38,6 +41,20 @@ public DeclarationSyntaxRewriter(SemanticModel semanticModel)
{
visitedNode = ((ParameterSyntax)visitedNode).WithDefault(null);
}

// Ref-like types (Span<T>, ReadOnlySpan<T>, 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."));
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reporting EFP0013 for ref-like parameter types is good, but the generator still proceeds to generate Expression<Func<...>> using that ref-like type in the Func<...> type arguments. Ref-like types (e.g. ReadOnlySpan<T>) cannot be used as generic arguments, so this will cause additional compiler errors in the generated .g.cs beyond EFP0013, undermining the goal of a single clear diagnostic. Consider short-circuiting descriptor generation for this member (return false from the body processor / GetDescriptor) or rewriting the parameter type to a safe placeholder solely to keep generated code compiling when EFP0013 is emitted.

Suggested change
$"Ref-like types cannot be used as parameters in expression trees."));
$"Ref-like types cannot be used as parameters in expression trees."));
// Replace the ref-like parameter type with a non-ref-like placeholder to keep generated code compiling.
visitedNode = ((ParameterSyntax)visitedNode).WithType(
SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.ObjectKeyword)));

Copilot uses AI. Check for mistakes.
}
}
}

return visitedNode;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using EntityFrameworkCore.Projectables.Generator.Infrastructure;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
Expand All @@ -14,19 +15,40 @@ 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)
/// <summary>
/// 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.
/// </summary>
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;
_expandEnumMethods = expandEnumMethods;
_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
Expand Down Expand Up @@ -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!)
};
Expand Down Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using Microsoft.CodeAnalysis;
using Xunit;

namespace EntityFrameworkCore.Projectables.Generator.Tests;

/// <summary>
/// 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.
/// </summary>
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<int> 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<int> 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
}
Loading