From 68c5e281df7a99b90d3e1475ad402081f883b492 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:52:26 +0000 Subject: [PATCH 1/3] Initial plan From 49aed4ac11fecdacb4d0a690d9c7b512b1b57978 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:11:21 +0000 Subject: [PATCH 2/3] Add C# 14 extension members support with Roslyn 5.0 Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- Directory.Build.props | 1 + Directory.Packages.props | 2 +- .../ExpressionSyntaxRewriter.cs | 26 ++- .../ProjectableInterpreter.cs | 78 +++++++- .../ExtensionMembers/Entity.cs | 10 + .../ExtensionMembers/EntityExtensions.cs | 40 ++++ ...mberMethodOnEntity.DotNet10_0.verified.txt | 2 + ...ExtensionMemberMethodOnEntity.verified.txt | 2 + ...hParameterOnEntity.DotNet10_0.verified.txt | 2 + ...erMethodWithParameterOnEntity.verified.txt | 2 + .../ExtensionMembers/ExtensionMemberTests.cs | 43 ++++ ...ensionMemberMethod.DotNet10_0.verified.txt | 17 ++ ...orTests.ExtensionMemberMethod.verified.txt | 17 ++ ...thodWithParameters.DotNet10_0.verified.txt | 17 ++ ...ionMemberMethodWithParameters.verified.txt | 17 ++ ...nMemberOnPrimitive.DotNet10_0.verified.txt | 16 ++ ...ts.ExtensionMemberOnPrimitive.verified.txt | 16 ++ ...sionMemberProperty.DotNet10_0.verified.txt | 17 ++ ...Tests.ExtensionMemberProperty.verified.txt | 17 ++ ...erWithMemberAccess.DotNet10_0.verified.txt | 17 ++ ...tensionMemberWithMemberAccess.verified.txt | 17 ++ .../ProjectionExpressionGeneratorTests.cs | 186 ++++++++++++++++++ 22 files changed, 549 insertions(+), 13 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/Entity.cs create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/EntityExtensions.cs create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodOnEntity.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodOnEntity.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodWithParameterOnEntity.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodWithParameterOnEntity.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.cs create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethod.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethod.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethodWithParameters.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethodWithParameters.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnPrimitive.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnPrimitive.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberProperty.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberProperty.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithMemberAccess.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithMemberAccess.verified.txt diff --git a/Directory.Build.props b/Directory.Build.props index 726e407..b2e626b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,7 @@ net8.0;net10.0 true 12.0 + preview enable true CS1591 diff --git a/Directory.Packages.props b/Directory.Packages.props index 7f8e683..f668a8b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,7 +24,7 @@ - + diff --git a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs index 6340d90..7b2f5af 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs @@ -18,13 +18,15 @@ public class ExpressionSyntaxRewriter : CSharpSyntaxRewriter readonly NullConditionalRewriteSupport _nullConditionalRewriteSupport; readonly SourceProductionContext _context; readonly Stack _conditionalAccessExpressionsStack = new(); + readonly string? _extensionParameterName; - public ExpressionSyntaxRewriter(INamedTypeSymbol targetTypeSymbol, NullConditionalRewriteSupport nullConditionalRewriteSupport, SemanticModel semanticModel, SourceProductionContext context) + public ExpressionSyntaxRewriter(INamedTypeSymbol targetTypeSymbol, NullConditionalRewriteSupport nullConditionalRewriteSupport, SemanticModel semanticModel, SourceProductionContext context, string? extensionParameterName = null) { _targetTypeSymbol = targetTypeSymbol; _nullConditionalRewriteSupport = nullConditionalRewriteSupport; _semanticModel = semanticModel; _context = context; + _extensionParameterName = extensionParameterName; } private SyntaxNode? VisitThisBaseExpression(CSharpSyntaxNode node) @@ -281,8 +283,22 @@ public ExpressionSyntaxRewriter(INamedTypeSymbol targetTypeSymbol, NullCondition public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) { - var symbol = _semanticModel.GetSymbolInfo(node).Symbol; - if (symbol is not null) + // Handle C# 14 extension parameter replacement (e.g., `e` in `extension(Entity e)` becomes `@this`) + if (_extensionParameterName is not null && node.Identifier.Text == _extensionParameterName) + { + var symbol = _semanticModel.GetSymbolInfo(node).Symbol; + // Check if this identifier refers to the extension parameter + if (symbol is IParameterSymbol paramSymbol && + paramSymbol.ContainingSymbol is INamedTypeSymbol { IsExtension: true }) + { + return SyntaxFactory.IdentifierName("@this") + .WithLeadingTrivia(node.GetLeadingTrivia()) + .WithTrailingTrivia(node.GetTrailingTrivia()); + } + } + + var identifierSymbol = _semanticModel.GetSymbolInfo(node).Symbol; + if (identifierSymbol is not null) { var operation = node switch { { Parent: { } parent } when parent.IsKind(SyntaxKind.InvocationExpression) => _semanticModel.GetOperation(node.Parent), _ => _semanticModel.GetOperation(node!) @@ -337,10 +353,10 @@ public ExpressionSyntaxRewriter(INamedTypeSymbol targetTypeSymbol, NullCondition } // if this node refers to a named type which is not yet fully qualified, we want to fully qualify it - if (symbol.Kind is SymbolKind.NamedType && node.Parent?.Kind() is not SyntaxKind.QualifiedName) + if (identifierSymbol.Kind is SymbolKind.NamedType && node.Parent?.Kind() is not SyntaxKind.QualifiedName) { return SyntaxFactory.ParseTypeName( - symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + identifierSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ).WithLeadingTrivia(node.GetLeadingTrivia()); } } diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index 339b46b..9bef083 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -22,6 +22,22 @@ static IEnumerable GetNestedInClassPath(ITypeSymbol namedTypeSymbol) yield return namedTypeSymbol.Name; } + /// + /// Gets the nested class path for extension members, skipping the extension block itself + /// and using the outer class as the containing type. + /// + static IEnumerable GetNestedInClassPathForExtensionMember(ITypeSymbol extensionType) + { + // For extension members, the ContainingType is the extension block, + // and its ContainingType is the outer class (e.g., EntityExtensions) + var outerType = extensionType.ContainingType; + if (outerType is not null) + { + return GetNestedInClassPath(outerType); + } + return Enumerable.Empty(); + } + public static ProjectableDescriptor? GetDescriptor(Compilation compilation, MemberDeclarationSyntax member, SourceProductionContext context) { var semanticModel = compilation.GetSemanticModel(member.SyntaxTree); @@ -115,20 +131,48 @@ x is IPropertySymbol xProperty && if (memberBody is null) return null; } - var expressionSyntaxRewriter = new ExpressionSyntaxRewriter(memberSymbol.ContainingType, nullConditionalRewriteSupport, semanticModel, context); + // Check if this member is inside a C# 14 extension block + var isExtensionMember = memberSymbol.ContainingType is INamedTypeSymbol { IsExtension: true }; + IParameterSymbol? extensionParameter = null; + ITypeSymbol? extensionReceiverType = null; + + if (isExtensionMember && memberSymbol.ContainingType is INamedTypeSymbol extensionType) + { + extensionParameter = extensionType.ExtensionParameter; + extensionReceiverType = extensionParameter?.Type; + } + + // For extension members, use the extension receiver type for rewriting + var targetTypeForRewriting = isExtensionMember && extensionReceiverType is INamedTypeSymbol receiverNamedType + ? receiverNamedType + : memberSymbol.ContainingType; + + var expressionSyntaxRewriter = new ExpressionSyntaxRewriter( + targetTypeForRewriting, + nullConditionalRewriteSupport, + semanticModel, + context, + extensionParameter?.Name); var declarationSyntaxRewriter = new DeclarationSyntaxRewriter(semanticModel); + // For extension members, use the outer class for class naming + var classForNaming = isExtensionMember && memberSymbol.ContainingType.ContainingType is not null + ? memberSymbol.ContainingType.ContainingType + : memberSymbol.ContainingType; + var descriptor = new ProjectableDescriptor { UsingDirectives = member.SyntaxTree.GetRoot().DescendantNodes().OfType(), - ClassName = memberSymbol.ContainingType.Name, - ClassNamespace = memberSymbol.ContainingType.ContainingNamespace.IsGlobalNamespace ? null : memberSymbol.ContainingType.ContainingNamespace.ToDisplayString(), + ClassName = classForNaming.Name, + ClassNamespace = classForNaming.ContainingNamespace.IsGlobalNamespace ? null : classForNaming.ContainingNamespace.ToDisplayString(), MemberName = memberSymbol.Name, - NestedInClassNames = GetNestedInClassPath(memberSymbol.ContainingType), + NestedInClassNames = isExtensionMember + ? GetNestedInClassPathForExtensionMember(memberSymbol.ContainingType) + : GetNestedInClassPath(memberSymbol.ContainingType), ParametersList = SyntaxFactory.ParameterList() }; - if (memberSymbol.ContainingType is INamedTypeSymbol { IsGenericType: true } containingNamedType) + if (classForNaming is INamedTypeSymbol { IsGenericType: true } containingNamedType) { descriptor.ClassTypeParameterList = SyntaxFactory.TypeParameterList(); @@ -182,7 +226,21 @@ x is IPropertySymbol xProperty && } } - if (!member.Modifiers.Any(SyntaxKind.StaticKeyword)) + // Handle extension members - add @this parameter with the extension receiver type + if (isExtensionMember && extensionReceiverType is not null) + { + descriptor.ParametersList = descriptor.ParametersList.AddParameters( + SyntaxFactory.Parameter( + SyntaxFactory.Identifier("@this") + ) + .WithType( + SyntaxFactory.ParseTypeName( + extensionReceiverType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ) + ) + ); + } + else if (!member.Modifiers.Any(SyntaxKind.StaticKeyword)) { descriptor.ParametersList = descriptor.ParametersList.AddParameters( SyntaxFactory.Parameter( @@ -198,7 +256,13 @@ x is IPropertySymbol xProperty && var methodSymbol = memberSymbol as IMethodSymbol; - if (methodSymbol is { IsExtensionMethod: true }) + // Handle target type for extension members + if (isExtensionMember && extensionReceiverType is not null) + { + descriptor.TargetClassNamespace = extensionReceiverType.ContainingNamespace.IsGlobalNamespace ? null : extensionReceiverType.ContainingNamespace.ToDisplayString(); + descriptor.TargetNestedInClassNames = GetNestedInClassPath(extensionReceiverType); + } + else if (methodSymbol is { IsExtensionMethod: true }) { var targetTypeSymbol = methodSymbol.Parameters.First().Type; descriptor.TargetClassNamespace = targetTypeSymbol.ContainingNamespace.IsGlobalNamespace ? null : targetTypeSymbol.ContainingNamespace.ToDisplayString(); diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/Entity.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/Entity.cs new file mode 100644 index 0000000..4edc475 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/Entity.cs @@ -0,0 +1,10 @@ +#if NET10_0_OR_GREATER +namespace EntityFrameworkCore.Projectables.FunctionalTests.ExtensionMembers +{ + public class Entity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } +} +#endif diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/EntityExtensions.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/EntityExtensions.cs new file mode 100644 index 0000000..f0c8c21 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/EntityExtensions.cs @@ -0,0 +1,40 @@ +#if NET10_0_OR_GREATER +namespace EntityFrameworkCore.Projectables.FunctionalTests.ExtensionMembers +{ + public static class EntityExtensions + { + extension(Entity e) + { + /// + /// Extension member property that doubles the entity's ID. + /// + [Projectable] + public int DoubleId => e.Id * 2; + + /// + /// Extension member method that triples the entity's ID. + /// + [Projectable] + public int TripleId() => e.Id * 3; + + /// + /// Extension member method that multiplies the entity's ID by a factor. + /// + [Projectable] + public int Multiply(int factor) => e.Id * factor; + } + } + + public static class IntExtensions + { + extension(int i) + { + /// + /// Extension member property that squares an integer. + /// + [Projectable] + public int SquaredMember => i * i; + } + } +} +#endif diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodOnEntity.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodOnEntity.DotNet10_0.verified.txt new file mode 100644 index 0000000..a7fa9cd --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodOnEntity.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] * 3 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodOnEntity.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodOnEntity.verified.txt new file mode 100644 index 0000000..a7fa9cd --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodOnEntity.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] * 3 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodWithParameterOnEntity.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodWithParameterOnEntity.DotNet10_0.verified.txt new file mode 100644 index 0000000..4f887bb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodWithParameterOnEntity.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] * 5 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodWithParameterOnEntity.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodWithParameterOnEntity.verified.txt new file mode 100644 index 0000000..4f887bb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.ExtensionMemberMethodWithParameterOnEntity.verified.txt @@ -0,0 +1,2 @@ +SELECT [e].[Id] * 5 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.cs new file mode 100644 index 0000000..28e333f --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionMembers/ExtensionMemberTests.cs @@ -0,0 +1,43 @@ +#if NET10_0_OR_GREATER +using System.Linq; +using System.Threading.Tasks; +using EntityFrameworkCore.Projectables.FunctionalTests.Helpers; +using Microsoft.EntityFrameworkCore; +using VerifyXunit; +using Xunit; + +namespace EntityFrameworkCore.Projectables.FunctionalTests.ExtensionMembers +{ + /// + /// Tests for C# 14 extension member support. + /// These tests only run on .NET 10+ where extension members are supported. + /// Note: Extension properties cannot currently be used directly in LINQ expression trees (CS9296), + /// so only extension methods are tested here. + /// + [UsesVerify] + public class ExtensionMemberTests + { + [Fact] + public Task ExtensionMemberMethodOnEntity() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(x => x.TripleId()); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task ExtensionMemberMethodWithParameterOnEntity() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(x => x.Multiply(5)); + + return Verifier.Verify(query.ToQueryString()); + } + } +} +#endif diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethod.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethod.DotNet10_0.verified.txt new file mode 100644 index 0000000..ee59e0c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethod.DotNet10_0.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_TripleId + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Id * 3; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethod.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethod.verified.txt new file mode 100644 index 0000000..ee59e0c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethod.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_TripleId + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Id * 3; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethodWithParameters.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethodWithParameters.DotNet10_0.verified.txt new file mode 100644 index 0000000..c39165d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethodWithParameters.DotNet10_0.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_Multiply + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this, int factor) => @this.Id * factor; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethodWithParameters.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethodWithParameters.verified.txt new file mode 100644 index 0000000..c39165d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberMethodWithParameters.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_Multiply + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this, int factor) => @this.Id * factor; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnPrimitive.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnPrimitive.DotNet10_0.verified.txt new file mode 100644 index 0000000..f6a60da --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnPrimitive.DotNet10_0.verified.txt @@ -0,0 +1,16 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class _IntExtensions_Squared + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int @this) => @this * @this; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnPrimitive.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnPrimitive.verified.txt new file mode 100644 index 0000000..f6a60da --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberOnPrimitive.verified.txt @@ -0,0 +1,16 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class _IntExtensions_Squared + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int @this) => @this * @this; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberProperty.DotNet10_0.verified.txt new file mode 100644 index 0000000..0d29557 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberProperty.DotNet10_0.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_DoubleId + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Id * 2; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberProperty.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberProperty.verified.txt new file mode 100644 index 0000000..0d29557 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberProperty.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_DoubleId + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Id * 2; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithMemberAccess.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithMemberAccess.DotNet10_0.verified.txt new file mode 100644 index 0000000..5707b4d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithMemberAccess.DotNet10_0.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_IdAndName + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Id + ": " + @this.Name; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithMemberAccess.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithMemberAccess.verified.txt new file mode 100644 index 0000000..5707b4d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ExtensionMemberWithMemberAccess.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_EntityExtensions_IdAndName + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.Entity @this) => @this.Id + ": " + @this.Name; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs index deb266a..cf074b7 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs @@ -1902,6 +1902,148 @@ public Task GenericTypesWithConstraints() return Verifier.Verify(result.GeneratedTrees[0].ToString()); } +#if NET10_0 + [Fact] + public Task ExtensionMemberProperty() + { + var compilation = CreateCompilationWithPreviewLanguageVersion(@" +using System; +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Id { get; set; } + } + + static class EntityExtensions { + extension(Entity e) { + [Projectable] + public int DoubleId => e.Id * 2; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExtensionMemberMethod() + { + var compilation = CreateCompilationWithPreviewLanguageVersion(@" +using System; +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Id { get; set; } + } + + static class EntityExtensions { + extension(Entity e) { + [Projectable] + public int TripleId() => e.Id * 3; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExtensionMemberMethodWithParameters() + { + var compilation = CreateCompilationWithPreviewLanguageVersion(@" +using System; +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Id { get; set; } + } + + static class EntityExtensions { + extension(Entity e) { + [Projectable] + public int Multiply(int factor) => e.Id * factor; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExtensionMemberOnPrimitive() + { + var compilation = CreateCompilationWithPreviewLanguageVersion(@" +using System; +using EntityFrameworkCore.Projectables; + +static class IntExtensions { + extension(int i) { + [Projectable] + public int Squared => i * i; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ExtensionMemberWithMemberAccess() + { + var compilation = CreateCompilationWithPreviewLanguageVersion(@" +using System; +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Entity { + public int Id { get; set; } + public string Name { get; set; } + } + + static class EntityExtensions { + extension(Entity e) { + [Projectable] + public string IdAndName => e.Id + "": "" + e.Name; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } +#endif + #region Helpers Compilation CreateCompilation(string source, bool expectedToCompile = true) @@ -1949,6 +2091,50 @@ Compilation CreateCompilation(string source, bool expectedToCompile = true) return compilation; } +#if NET10_0 + /// + /// Creates a compilation with preview language version to support C# 14 features like extension members. + /// + Compilation CreateCompilationWithPreviewLanguageVersion(string source, bool expectedToCompile = true) + { + var references = Basic.Reference.Assemblies.Net100.References.All.ToList(); + + references.Add(MetadataReference.CreateFromFile(typeof(ProjectableAttribute).Assembly.Location)); + + var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); + + var compilation = CSharpCompilation.Create("compilation", + new[] { CSharpSyntaxTree.ParseText(source, parseOptions) }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + +#if DEBUG + + if (expectedToCompile) + { + 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; + } +#endif + private GeneratorDriverRunResult RunGenerator(Compilation compilation) { _testOutputHelper.WriteLine("Running generator and updating compilation..."); From 7dcd77711a9769bc6e5f3f73397caa5f546a2a5c Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Wed, 14 Jan 2026 22:49:03 +0100 Subject: [PATCH 3/3] Code cleanup --- Directory.Build.props | 2 +- .../ExpressionSyntaxRewriter.cs | 4 +- .../ProjectableInterpreter.cs | 16 +++--- .../ProjectionExpressionGeneratorTests.cs | 56 ++----------------- 4 files changed, 18 insertions(+), 60 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index b2e626b..876b646 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0;net10.0 true 12.0 - preview + 14.0 enable true CS1591 diff --git a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs index 7b2f5af..62ad03d 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ExpressionSyntaxRewriter.cs @@ -287,9 +287,9 @@ public ExpressionSyntaxRewriter(INamedTypeSymbol targetTypeSymbol, NullCondition if (_extensionParameterName is not null && node.Identifier.Text == _extensionParameterName) { var symbol = _semanticModel.GetSymbolInfo(node).Symbol; + // Check if this identifier refers to the extension parameter - if (symbol is IParameterSymbol paramSymbol && - paramSymbol.ContainingSymbol is INamedTypeSymbol { IsExtension: true }) + if (symbol is IParameterSymbol { ContainingSymbol: INamedTypeSymbol { IsExtension: true } }) { return SyntaxFactory.IdentifierName("@this") .WithLeadingTrivia(node.GetLeadingTrivia()) diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index 9bef083..8d98415 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -31,11 +31,13 @@ static IEnumerable GetNestedInClassPathForExtensionMember(ITypeSymbol ex // For extension members, the ContainingType is the extension block, // and its ContainingType is the outer class (e.g., EntityExtensions) var outerType = extensionType.ContainingType; + if (outerType is not null) { return GetNestedInClassPath(outerType); } - return Enumerable.Empty(); + + return []; } public static ProjectableDescriptor? GetDescriptor(Compilation compilation, MemberDeclarationSyntax member, SourceProductionContext context) @@ -132,11 +134,11 @@ x is IPropertySymbol xProperty && } // Check if this member is inside a C# 14 extension block - var isExtensionMember = memberSymbol.ContainingType is INamedTypeSymbol { IsExtension: true }; + var isExtensionMember = memberSymbol.ContainingType is { IsExtension: true }; IParameterSymbol? extensionParameter = null; ITypeSymbol? extensionReceiverType = null; - if (isExtensionMember && memberSymbol.ContainingType is INamedTypeSymbol extensionType) + if (isExtensionMember && memberSymbol.ContainingType is { } extensionType) { extensionParameter = extensionType.ExtensionParameter; extensionReceiverType = extensionParameter?.Type; @@ -160,8 +162,8 @@ x is IPropertySymbol xProperty && ? memberSymbol.ContainingType.ContainingType : memberSymbol.ContainingType; - var descriptor = new ProjectableDescriptor { - + var descriptor = new ProjectableDescriptor + { UsingDirectives = member.SyntaxTree.GetRoot().DescendantNodes().OfType(), ClassName = classForNaming.Name, ClassNamespace = classForNaming.ContainingNamespace.IsGlobalNamespace ? null : classForNaming.ContainingNamespace.ToDisplayString(), @@ -172,11 +174,11 @@ x is IPropertySymbol xProperty && ParametersList = SyntaxFactory.ParameterList() }; - if (classForNaming is INamedTypeSymbol { IsGenericType: true } containingNamedType) + if (classForNaming is { IsGenericType: true }) { descriptor.ClassTypeParameterList = SyntaxFactory.TypeParameterList(); - foreach (var additionalClassTypeParameter in containingNamedType.TypeParameters) + foreach (var additionalClassTypeParameter in classForNaming.TypeParameters) { descriptor.ClassTypeParameterList = descriptor.ClassTypeParameterList.AddParameters( SyntaxFactory.TypeParameter(additionalClassTypeParameter.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs index cf074b7..627441c 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs @@ -1902,11 +1902,11 @@ public Task GenericTypesWithConstraints() return Verifier.Verify(result.GeneratedTrees[0].ToString()); } -#if NET10_0 +#if NET10_0_OR_GREATER [Fact] public Task ExtensionMemberProperty() { - var compilation = CreateCompilationWithPreviewLanguageVersion(@" + var compilation = CreateCompilation(@" using System; using EntityFrameworkCore.Projectables; @@ -1935,7 +1935,7 @@ static class EntityExtensions { [Fact] public Task ExtensionMemberMethod() { - var compilation = CreateCompilationWithPreviewLanguageVersion(@" + var compilation = CreateCompilation(@" using System; using EntityFrameworkCore.Projectables; @@ -1964,7 +1964,7 @@ static class EntityExtensions { [Fact] public Task ExtensionMemberMethodWithParameters() { - var compilation = CreateCompilationWithPreviewLanguageVersion(@" + var compilation = CreateCompilation(@" using System; using EntityFrameworkCore.Projectables; @@ -1993,7 +1993,7 @@ static class EntityExtensions { [Fact] public Task ExtensionMemberOnPrimitive() { - var compilation = CreateCompilationWithPreviewLanguageVersion(@" + var compilation = CreateCompilation(@" using System; using EntityFrameworkCore.Projectables; @@ -2016,7 +2016,7 @@ static class IntExtensions { [Fact] public Task ExtensionMemberWithMemberAccess() { - var compilation = CreateCompilationWithPreviewLanguageVersion(@" + var compilation = CreateCompilation(@" using System; using EntityFrameworkCore.Projectables; @@ -2091,50 +2091,6 @@ Compilation CreateCompilation(string source, bool expectedToCompile = true) return compilation; } -#if NET10_0 - /// - /// Creates a compilation with preview language version to support C# 14 features like extension members. - /// - Compilation CreateCompilationWithPreviewLanguageVersion(string source, bool expectedToCompile = true) - { - var references = Basic.Reference.Assemblies.Net100.References.All.ToList(); - - references.Add(MetadataReference.CreateFromFile(typeof(ProjectableAttribute).Assembly.Location)); - - var parseOptions = new CSharpParseOptions(LanguageVersion.Preview); - - var compilation = CSharpCompilation.Create("compilation", - new[] { CSharpSyntaxTree.ParseText(source, parseOptions) }, - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - -#if DEBUG - - if (expectedToCompile) - { - 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; - } -#endif - private GeneratorDriverRunResult RunGenerator(Compilation compilation) { _testOutputHelper.WriteLine("Running generator and updating compilation...");