From 3f6cb133e7a8e53b29fa5f9b8423afd5616085f6 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sat, 21 Feb 2026 23:26:14 +0100 Subject: [PATCH 1/8] Add support for projectable constructors --- .../ProjectableAttribute.cs | 4 +- .../ProjectableInterpreter.cs | 105 +++++++++++- .../Services/ProjectableExpressionReplacer.cs | 36 ++++- .../Services/ProjectionExpressionResolver.cs | 11 +- ...ithBaseConstructor.DotNet10_0.verified.txt | 2 + ...WithBaseConstructor.DotNet9_0.verified.txt | 2 + ...DerivedDtoWithBaseConstructor.verified.txt | 2 + ...ntityInstanceToDto.DotNet10_0.verified.txt | 2 + ...EntityInstanceToDto.DotNet9_0.verified.txt | 2 + ...ts.Select_EntityInstanceToDto.verified.txt | 2 + ...ctor_WithThreeArgs.DotNet10_0.verified.txt | 2 + ...uctor_WithThreeArgs.DotNet9_0.verified.txt | 2 + ...adedConstructor_WithThreeArgs.verified.txt | 2 + ...ructor_WithTwoArgs.DotNet10_0.verified.txt | 2 + ...tructor_WithTwoArgs.DotNet9_0.verified.txt | 2 + ...loadedConstructor_WithTwoArgs.verified.txt | 2 + ..._ScalarFieldsToDto.DotNet10_0.verified.txt | 2 + ...t_ScalarFieldsToDto.DotNet9_0.verified.txt | 2 + ...ests.Select_ScalarFieldsToDto.verified.txt | 2 + .../ProjectableConstructorTests.cs | 153 ++++++++++++++++++ ...leConstructor_BodyAssignments.verified.txt | 20 +++ ...jectableConstructor_Overloads.verified.txt | 20 +++ ...nstructor_WithBaseInitializer.verified.txt | 19 +++ ...Constructor_WithClassArgument.verified.txt | 20 +++ ...or_WithMultipleClassArguments.verified.txt | 20 +++ .../ProjectionExpressionGeneratorTests.cs | 153 ++++++++++++++++++ 26 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_BodyAssignments.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_Overloads.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithClassArgument.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithMultipleClassArguments.verified.txt diff --git a/src/EntityFrameworkCore.Projectables.Abstractions/ProjectableAttribute.cs b/src/EntityFrameworkCore.Projectables.Abstractions/ProjectableAttribute.cs index 94b63b2..6edbee0 100644 --- a/src/EntityFrameworkCore.Projectables.Abstractions/ProjectableAttribute.cs +++ b/src/EntityFrameworkCore.Projectables.Abstractions/ProjectableAttribute.cs @@ -1,10 +1,10 @@ namespace EntityFrameworkCore.Projectables { /// - /// Declares this property or method to be Projectable. + /// Declares this property, method or constructor to be Projectable. /// A companion Expression tree will be generated /// - [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Constructor, Inherited = true, AllowMultiple = false)] public sealed class ProjectableAttribute : Attribute { /// diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index 3037fb1..ce6418e 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -188,19 +188,24 @@ x is IPropertySymbol xProperty && ? memberSymbol.ContainingType.ContainingType : memberSymbol.ContainingType; + var methodSymbol = memberSymbol as IMethodSymbol; + + // Sanitize constructor name (.ctor / .cctor are not valid C# identifiers, use _ctor) + var memberName = methodSymbol?.MethodKind is MethodKind.Constructor or MethodKind.StaticConstructor + ? "_ctor" + : memberSymbol.Name; + var descriptor = new ProjectableDescriptor { UsingDirectives = member.SyntaxTree.GetRoot().DescendantNodes().OfType(), ClassName = classForNaming.Name, ClassNamespace = classForNaming.ContainingNamespace.IsGlobalNamespace ? null : classForNaming.ContainingNamespace.ToDisplayString(), - MemberName = memberSymbol.Name, + MemberName = memberName, NestedInClassNames = isExtensionMember ? GetNestedInClassPathForExtensionMember(memberSymbol.ContainingType) : GetNestedInClassPath(memberSymbol.ContainingType), ParametersList = SyntaxFactory.ParameterList() }; - - var methodSymbol = memberSymbol as IMethodSymbol; // Collect parameter type names for method overload disambiguation if (methodSymbol is not null) @@ -288,7 +293,7 @@ x is IPropertySymbol xProperty && ) ); } - else if (!member.Modifiers.Any(SyntaxKind.StaticKeyword)) + else if (!member.Modifiers.Any(SyntaxKind.StaticKeyword) && member is not ConstructorDeclarationSyntax) { descriptor.ParametersList = descriptor.ParametersList.AddParameters( SyntaxFactory.Parameter( @@ -452,6 +457,98 @@ x is IPropertySymbol xProperty && ? bodyExpression : (ExpressionSyntax)expressionSyntaxRewriter.Visit(bodyExpression); } + // Projectable constructors + else if (memberBody is ConstructorDeclarationSyntax constructorDeclarationSyntax) + { + var containingType = memberSymbol.ContainingType; + var fullTypeName = containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + 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); + } + + + // Build member-initializer list from block-body assignments (this.X = y) + InitializerExpressionSyntax? memberInit = null; + if (constructorDeclarationSyntax.Body is { } body && body.Statements.Count > 0) + { + var initExpressions = new List(); + foreach (var statement in body.Statements) + { + if (statement is ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax assignment } + && assignment.IsKind(SyntaxKind.SimpleAssignmentExpression)) + { + // Accept: this.X = y or X = y + var targetMember = assignment.Left switch + { + MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax, Name: var name } => name, + IdentifierNameSyntax ident => ident, + _ => null + }; + + if (targetMember is null) + { + var diag = Diagnostic.Create(Diagnostics.UnsupportedStatementInBlockBody, + statement.GetLocation(), memberSymbol.Name, statement.ToString()); + context.ReportDiagnostic(diag); + return null; + } + + var rewrittenRight = (ExpressionSyntax)expressionSyntaxRewriter.Visit(assignment.Right); + initExpressions.Add( + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + targetMember, + rewrittenRight + ) + ); + } + else + { + var diag = Diagnostic.Create(Diagnostics.UnsupportedStatementInBlockBody, + statement.GetLocation(), memberSymbol.Name, statement.ToString()); + context.ReportDiagnostic(diag); + return null; + } + } + + if (initExpressions.Count > 0) + { + memberInit = SyntaxFactory.InitializerExpression( + SyntaxKind.ObjectInitializerExpression, + SyntaxFactory.SeparatedList(initExpressions) + ); + } + } + + if (memberInit is null) + { + var diag = Diagnostic.Create(Diagnostics.RequiresBodyDefinition, + constructorDeclarationSyntax.GetLocation(), memberSymbol.Name); + context.ReportDiagnostic(diag); + return null; + } + + // Always pass the constructor's own parameters as ctor call args. + // The base/this initializer is handled transparently at runtime when the constructor is invoked. + // Using own params ensures the generated expression compiles for every constructor signature. + var selfArgs = constructorDeclarationSyntax.ParameterList.Parameters + .Select(p => SyntaxFactory.Argument(SyntaxFactory.IdentifierName(p.Identifier))) + .ToList(); + var initializerArgs = SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(selfArgs)); + + // new ClassName(initializerArgs) { MemberInit } + descriptor.ExpressionBody = SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.Token(SyntaxKind.NewKeyword).WithTrailingTrivia(SyntaxFactory.Space), + SyntaxFactory.ParseTypeName(fullTypeName), + initializerArgs, + memberInit + ); + } else { return null; diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index 8f9f4a1..e7142d8 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -1,4 +1,4 @@ -using System.Collections; +using System.Collections; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; @@ -15,6 +15,7 @@ public sealed class ProjectableExpressionReplacer : ExpressionVisitor private readonly IProjectionExpressionResolver _resolver; private readonly ExpressionArgumentReplacer _expressionArgumentReplacer = new(); private readonly Dictionary _projectableMemberCache = new(); + private readonly HashSet _expandingConstructors = new(); private IQueryProvider? _currentQueryProvider; private bool _disableRootRewrite = false; private readonly bool _trackingByDefault; @@ -203,6 +204,39 @@ protected override Expression VisitMethodCall(MethodCallExpression node) return base.VisitMethodCall(node); } + protected override Expression VisitNew(NewExpression node) + { + var constructor = node.Constructor; + if (constructor is not null && + !_expandingConstructors.Contains(constructor) && + TryGetReflectedExpression(constructor, out var reflectedExpression)) + { + _expandingConstructors.Add(constructor); + try + { + for (var parameterIndex = 0; parameterIndex < reflectedExpression.Parameters.Count; parameterIndex++) + { + var parameterExpression = reflectedExpression.Parameters[parameterIndex]; + if (parameterIndex < node.Arguments.Count) + { + _expressionArgumentReplacer.ParameterArgumentMapping.Add(parameterExpression, node.Arguments[parameterIndex]); + } + } + + var updatedBody = _expressionArgumentReplacer.Visit(reflectedExpression.Body); + _expressionArgumentReplacer.ParameterArgumentMapping.Clear(); + + return base.Visit(updatedBody); + } + finally + { + _expandingConstructors.Remove(constructor); + } + } + + return base.VisitNew(node); + } + protected override Expression VisitMember(MemberExpression node) { // Evaluate captured variables in closures that contain EF queries to inline them into the main query diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs index b6ded59..7e26d69 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs @@ -77,6 +77,7 @@ public LambdaExpression FindGeneratedExpression(MemberInfo projectableMemberInfo // Use the same format as Roslyn's SymbolDisplayFormat.FullyQualifiedFormat // which uses C# keywords for primitive types (int, string, etc.) string[]? parameterTypeNames = null; + string memberLookupName = projectableMemberInfo.Name; if (projectableMemberInfo is MethodInfo method) { // For generic methods, use the generic definition to get parameter types @@ -87,8 +88,16 @@ public LambdaExpression FindGeneratedExpression(MemberInfo projectableMemberInfo .Select(p => GetFullTypeName(p.ParameterType)) .ToArray(); } + else if (projectableMemberInfo is ConstructorInfo ctor) + { + // Constructors are stored under the synthetic name "_ctor" + memberLookupName = "_ctor"; + parameterTypeNames = ctor.GetParameters() + .Select(p => GetFullTypeName(p.ParameterType)) + .ToArray(); + } - var generatedContainingTypeName = ProjectionExpressionClassNameGenerator.GenerateFullName(declaringType.Namespace, declaringType.GetNestedTypePath().Select(x => x.Name), projectableMemberInfo.Name, parameterTypeNames); + var generatedContainingTypeName = ProjectionExpressionClassNameGenerator.GenerateFullName(declaringType.Namespace, declaringType.GetNestedTypePath().Select(x => x.Name), memberLookupName, parameterTypeNames); var expressionFactoryType = declaringType.Assembly.GetType(generatedContainingTypeName); diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt new file mode 100644 index 0000000..bdc2c92 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet9_0.verified.txt new file mode 100644 index 0000000..bdc2c92 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.verified.txt new file mode 100644 index 0000000..738723a --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt new file mode 100644 index 0000000..bdc2c92 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet9_0.verified.txt new file mode 100644 index 0000000..bdc2c92 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.verified.txt new file mode 100644 index 0000000..738723a --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt new file mode 100644 index 0000000..bdc2c92 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet9_0.verified.txt new file mode 100644 index 0000000..bdc2c92 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.verified.txt new file mode 100644 index 0000000..738723a --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt new file mode 100644 index 0000000..136dce6 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet9_0.verified.txt new file mode 100644 index 0000000..136dce6 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.verified.txt new file mode 100644 index 0000000..6e81c0c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt new file mode 100644 index 0000000..bdc2c92 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet9_0.verified.txt new file mode 100644 index 0000000..bdc2c92 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.verified.txt new file mode 100644 index 0000000..738723a --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs new file mode 100644 index 0000000..86368c5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs @@ -0,0 +1,153 @@ +using System.Linq; +using System.Threading.Tasks; +using EntityFrameworkCore.Projectables.FunctionalTests.Helpers; +using Microsoft.EntityFrameworkCore; +using VerifyXunit; +using Xunit; + +#nullable disable + +namespace EntityFrameworkCore.Projectables.FunctionalTests +{ + [UsesVerify] + public class ProjectableConstructorTests + { + // ── Entity ────────────────────────────────────────────────────────────── + public class PersonEntity + { + public int Id { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + } + + // ── DTOs ───────────────────────────────────────────────────────────────── + + /// DTO built from scalar entity fields. + public class PersonSummaryDto + { + public int Id { get; set; } + public string FullName { get; set; } + + [Projectable] + public PersonSummaryDto(int id, string firstName, string lastName) + { + Id = id; + FullName = firstName + " " + lastName; + } + } + + /// DTO built by passing the whole entity instance as the constructor argument. + public class PersonFromEntityDto + { + public int Id { get; set; } + public string FullName { get; set; } + + [Projectable] + public PersonFromEntityDto(PersonEntity entity) + { + Id = entity.Id; + FullName = entity.FirstName + " " + entity.LastName; + } + } + + // ── Base / derived DTO ──────────────────────────────────────────────────── + + public class BaseDto + { + public int Id { get; set; } + + public BaseDto(int id) { Id = id; } + } + + public class DerivedDto : BaseDto + { + public string FullName { get; set; } + + [Projectable] + public DerivedDto(int id, string firstName, string lastName) : base(id) + { + FullName = firstName + " " + lastName; + } + } + + // ── Overloaded constructors ─────────────────────────────────────────────── + + public class PersonOverloadedDto + { + public int Id { get; set; } + public string FullName { get; set; } + + [Projectable] + public PersonOverloadedDto(int id, string firstName, string lastName) + { + Id = id; + FullName = firstName + " " + lastName; + } + + [Projectable] + public PersonOverloadedDto(string firstName, string lastName) + { + Id = 0; + FullName = firstName + " " + lastName; + } + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + [Fact] + public Task Select_ScalarFieldsToDto() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonSummaryDto(p.Id, p.FirstName, p.LastName)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_EntityInstanceToDto() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonFromEntityDto(p)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_DerivedDtoWithBaseConstructor() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new DerivedDto(p.Id, p.FirstName, p.LastName)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_OverloadedConstructor_WithThreeArgs() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonOverloadedDto(p.Id, p.FirstName, p.LastName)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_OverloadedConstructor_WithTwoArgs() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonOverloadedDto(p.FirstName, p.LastName)); + + return Verifier.Verify(query.ToQueryString()); + } + } +} + diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_BodyAssignments.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_BodyAssignments.verified.txt new file mode 100644 index 0000000..13bca72 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_BodyAssignments.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PointDto__ctor_P0_int_P1_int + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int x, int y) => new global::Foo.PointDto(x, y) + { + X = x, + Y = y + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_Overloads.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_Overloads.verified.txt new file mode 100644 index 0000000..bb24233 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_Overloads.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string firstName, string lastName) => new global::Foo.PersonDto(firstName, lastName) + { + FirstName = firstName, + LastName = lastName + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt new file mode 100644 index 0000000..75cd79c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_int_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int id, string name) => new global::Foo.Child(id, name) + { + Name = name + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithClassArgument.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithClassArgument.verified.txt new file mode 100644 index 0000000..c5a2b62 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithClassArgument.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_Foo_SourceEntity + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.SourceEntity source) => new global::Foo.PersonDto(source) + { + Id = source.Id, + Name = source.Name + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithMultipleClassArguments.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithMultipleClassArguments.verified.txt new file mode 100644 index 0000000..3a7aead --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithMultipleClassArguments.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_Foo_NamePart_P1_Foo_NamePart + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.NamePart first, global::Foo.NamePart last) => new global::Foo.PersonDto(first, last) + { + FirstName = first.Value, + LastName = last.Value + }; + } + } +} \ 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 ea03112..3035641 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs @@ -3373,6 +3373,159 @@ public int GetDouble() Assert.Empty(result.Diagnostics); } + [Fact] + public Task ProjectableConstructor_BodyAssignments() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PointDto { + public int X { get; set; } + public int Y { get; set; } + + [Projectable] + public PointDto(int x, int y) { + X = x; + Y = y; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_WithBaseInitializer() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Base { + public int Id { get; set; } + public Base(int id) { Id = id; } + } + + class Child : Base { + public string Name { get; set; } + + [Projectable] + public Child(int id, string name) : base(id) { + Name = name; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_Overloads() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string FirstName { get; set; } + public string LastName { get; set; } + + [Projectable] + public PersonDto(string firstName, string lastName) { + FirstName = firstName; + LastName = lastName; + } + + [Projectable] + public PersonDto(string fullName) { + FirstName = fullName; + LastName = string.Empty; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Equal(2, result.GeneratedTrees.Length); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_WithClassArgument() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class SourceEntity { + public int Id { get; set; } + public string Name { get; set; } + } + + class PersonDto { + public int Id { get; set; } + public string Name { get; set; } + + [Projectable] + public PersonDto(SourceEntity source) { + Id = source.Id; + Name = source.Name; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_WithMultipleClassArguments() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class NamePart { + public string Value { get; set; } + } + + class PersonDto { + public string FirstName { get; set; } + public string LastName { get; set; } + + [Projectable] + public PersonDto(NamePart first, NamePart last) { + FirstName = first.Value; + LastName = last.Value; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + #region Helpers Compilation CreateCompilation(string source, bool expectedToCompile = true) From 9e222e05e6803f5b2e3e3e60200f79f1cdcedf87 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sat, 21 Feb 2026 23:48:34 +0100 Subject: [PATCH 2/8] Require parameterless constructor to improve optimized mapping by EF core --- .../ProjectableInterpreter.cs | 16 +++-- .../Services/ProjectableExpressionReplacer.cs | 2 +- ...ithBaseConstructor.DotNet10_0.verified.txt | 2 +- ...WithBaseConstructor.DotNet9_0.verified.txt | 2 +- ...DerivedDtoWithBaseConstructor.verified.txt | 2 +- ...ntityInstanceToDto.DotNet10_0.verified.txt | 2 +- ...EntityInstanceToDto.DotNet9_0.verified.txt | 2 +- ...ts.Select_EntityInstanceToDto.verified.txt | 2 +- ...ctor_WithThreeArgs.DotNet10_0.verified.txt | 2 +- ...uctor_WithThreeArgs.DotNet9_0.verified.txt | 2 +- ...adedConstructor_WithThreeArgs.verified.txt | 2 +- ...ructor_WithTwoArgs.DotNet10_0.verified.txt | 2 +- ...tructor_WithTwoArgs.DotNet9_0.verified.txt | 2 +- ...loadedConstructor_WithTwoArgs.verified.txt | 2 +- ..._ScalarFieldsToDto.DotNet10_0.verified.txt | 2 +- ...t_ScalarFieldsToDto.DotNet9_0.verified.txt | 2 +- ...ests.Select_ScalarFieldsToDto.verified.txt | 2 +- ...PropertyNotInQuery.DotNet10_0.verified.txt | 2 + ...dPropertyNotInQuery.DotNet9_0.verified.txt | 2 + ..._UnassignedPropertyNotInQuery.verified.txt | 2 + .../ProjectableConstructorTests.cs | 61 ++++++++++++++++++- ...leConstructor_BodyAssignments.verified.txt | 2 +- ...jectableConstructor_Overloads.verified.txt | 2 +- ...nstructor_WithBaseInitializer.verified.txt | 2 +- ...Constructor_WithClassArgument.verified.txt | 2 +- ...or_WithMultipleClassArguments.verified.txt | 2 +- 26 files changed, 94 insertions(+), 31 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.verified.txt diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index ce6418e..bc9a180 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -533,19 +533,17 @@ x is IPropertySymbol xProperty && return null; } - // Always pass the constructor's own parameters as ctor call args. - // The base/this initializer is handled transparently at runtime when the constructor is invoked. - // Using own params ensures the generated expression compiles for every constructor signature. - var selfArgs = constructorDeclarationSyntax.ParameterList.Parameters - .Select(p => SyntaxFactory.Argument(SyntaxFactory.IdentifierName(p.Identifier))) - .ToList(); - var initializerArgs = SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(selfArgs)); + // Use a parameterless constructor call + object initializer. + // This ensures EF Core only projects columns that are explicitly assigned + // in the constructor body — properties not listed here won't appear in SQL. + // Requirement: the DTO must expose a parameterless constructor (or init-only setters). + var emptyArgs = SyntaxFactory.ArgumentList(); - // new ClassName(initializerArgs) { MemberInit } + // new ClassName() { MemberInit } descriptor.ExpressionBody = SyntaxFactory.ObjectCreationExpression( SyntaxFactory.Token(SyntaxKind.NewKeyword).WithTrailingTrivia(SyntaxFactory.Space), SyntaxFactory.ParseTypeName(fullTypeName), - initializerArgs, + emptyArgs, memberInit ); } diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index e7142d8..6d815e2 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -1,4 +1,4 @@ -using System.Collections; +using System.Collections; using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt index bdc2c92..a8f3f2f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet9_0.verified.txt index bdc2c92..a8f3f2f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet9_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet9_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.verified.txt index 738723a..fa0395a 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt index bdc2c92..a8f3f2f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet9_0.verified.txt index bdc2c92..a8f3f2f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet9_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet9_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.verified.txt index 738723a..fa0395a 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt index bdc2c92..a8f3f2f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet9_0.verified.txt index bdc2c92..a8f3f2f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet9_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet9_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.verified.txt index 738723a..fa0395a 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt index 136dce6..dee2833 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet9_0.verified.txt index 136dce6..dee2833 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet9_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet9_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.verified.txt index 6e81c0c..8fc80f2 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt index bdc2c92..a8f3f2f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet9_0.verified.txt index bdc2c92..a8f3f2f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet9_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet9_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.verified.txt index 738723a..fa0395a 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt new file mode 100644 index 0000000..a8f3f2f --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet9_0.verified.txt new file mode 100644 index 0000000..a8f3f2f --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.verified.txt new file mode 100644 index 0000000..a8f3f2f --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs index 86368c5..05d3df3 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs @@ -28,6 +28,8 @@ public class PersonSummaryDto public int Id { get; set; } public string FullName { get; set; } + public PersonSummaryDto() { } // required: EF Core uses the parameterless ctor + [Projectable] public PersonSummaryDto(int id, string firstName, string lastName) { @@ -42,6 +44,8 @@ public class PersonFromEntityDto public int Id { get; set; } public string FullName { get; set; } + public PersonFromEntityDto() { } // required: EF Core uses the parameterless ctor + [Projectable] public PersonFromEntityDto(PersonEntity entity) { @@ -56,6 +60,7 @@ public class BaseDto { public int Id { get; set; } + public BaseDto() { } // required public BaseDto(int id) { Id = id; } } @@ -63,9 +68,17 @@ public class DerivedDto : BaseDto { public string FullName { get; set; } + public DerivedDto() { } // required: EF Core uses the parameterless ctor + + /// + /// Note: Id must be explicitly assigned here (not just via : base(id)) + /// because the generated projection uses a parameterless constructor + member-init; + /// the base-ctor call is invisible to EF Core. + /// [Projectable] public DerivedDto(int id, string firstName, string lastName) : base(id) { + Id = id; // explicit assignment so EF Core selects this column FullName = firstName + " " + lastName; } } @@ -77,6 +90,8 @@ public class PersonOverloadedDto public int Id { get; set; } public string FullName { get; set; } + public PersonOverloadedDto() { } // required: EF Core uses the parameterless ctor + [Projectable] public PersonOverloadedDto(int id, string firstName, string lastName) { @@ -87,11 +102,35 @@ public PersonOverloadedDto(int id, string firstName, string lastName) [Projectable] public PersonOverloadedDto(string firstName, string lastName) { - Id = 0; + // Id deliberately not set → should not appear as a DB column in the query FullName = firstName + " " + lastName; } } + // ── Partial / unmapped property ─────────────────────────────────────────── + + /// + /// DTO with a Nickname property that is intentionally NOT assigned + /// in the [Projectable] constructor body. + /// The generated SQL must NOT include a column for Nickname. + /// + public class PersonPartialDto + { + public int Id { get; set; } + public string FullName { get; set; } + public string Nickname { get; set; } // intentionally unmapped in the constructor + + public PersonPartialDto() { } // required + + [Projectable] + public PersonPartialDto(int id, string firstName, string lastName) + { + Id = id; + FullName = firstName + " " + lastName; + // Nickname is intentionally NOT assigned here + } + } + // ── Tests ───────────────────────────────────────────────────────────────── [Fact] @@ -148,6 +187,26 @@ public Task Select_OverloadedConstructor_WithTwoArgs() return Verifier.Verify(query.ToQueryString()); } + + /// + /// Verifies that a property not assigned in the [Projectable] constructor body + /// does NOT appear as a column in the generated SQL query. + /// + [Fact] + public Task Select_UnassignedPropertyNotInQuery() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonPartialDto(p.Id, p.FirstName, p.LastName)); + + var sql = query.ToQueryString(); + + // Nickname is not assigned in the constructor → must not appear in SQL + Assert.DoesNotContain("Nickname", sql, System.StringComparison.OrdinalIgnoreCase); + + return Verifier.Verify(sql); + } } } diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_BodyAssignments.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_BodyAssignments.verified.txt index 13bca72..e8cf46e 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_BodyAssignments.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_BodyAssignments.verified.txt @@ -10,7 +10,7 @@ namespace EntityFrameworkCore.Projectables.Generated { static global::System.Linq.Expressions.Expression> Expression() { - return (int x, int y) => new global::Foo.PointDto(x, y) + return (int x, int y) => new global::Foo.PointDto() { X = x, Y = y diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_Overloads.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_Overloads.verified.txt index bb24233..bd7f45c 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_Overloads.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_Overloads.verified.txt @@ -10,7 +10,7 @@ namespace EntityFrameworkCore.Projectables.Generated { static global::System.Linq.Expressions.Expression> Expression() { - return (string firstName, string lastName) => new global::Foo.PersonDto(firstName, lastName) + return (string firstName, string lastName) => new global::Foo.PersonDto() { FirstName = firstName, LastName = lastName diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt index 75cd79c..a69227c 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt @@ -10,7 +10,7 @@ namespace EntityFrameworkCore.Projectables.Generated { static global::System.Linq.Expressions.Expression> Expression() { - return (int id, string name) => new global::Foo.Child(id, name) + return (int id, string name) => new global::Foo.Child() { Name = name }; diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithClassArgument.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithClassArgument.verified.txt index c5a2b62..b91f020 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithClassArgument.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithClassArgument.verified.txt @@ -10,7 +10,7 @@ namespace EntityFrameworkCore.Projectables.Generated { static global::System.Linq.Expressions.Expression> Expression() { - return (global::Foo.SourceEntity source) => new global::Foo.PersonDto(source) + return (global::Foo.SourceEntity source) => new global::Foo.PersonDto() { Id = source.Id, Name = source.Name diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithMultipleClassArguments.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithMultipleClassArguments.verified.txt index 3a7aead..fa98281 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithMultipleClassArguments.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithMultipleClassArguments.verified.txt @@ -10,7 +10,7 @@ namespace EntityFrameworkCore.Projectables.Generated { static global::System.Linq.Expressions.Expression> Expression() { - return (global::Foo.NamePart first, global::Foo.NamePart last) => new global::Foo.PersonDto(first, last) + return (global::Foo.NamePart first, global::Foo.NamePart last) => new global::Foo.PersonDto() { FirstName = first.Value, LastName = last.Value From c5b1ab63ba5120ddf2498ec2b2246c845f5c3857 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sat, 21 Feb 2026 23:54:24 +0100 Subject: [PATCH 3/8] Improve base constructor mapping --- .../ProjectableInterpreter.cs | 151 ++++++++++++++---- .../ProjectableConstructorTests.cs | 5 +- 2 files changed, 124 insertions(+), 32 deletions(-) diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index bc9a180..d82f42f 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -471,18 +471,31 @@ x is IPropertySymbol xProperty && descriptor.ParametersList = descriptor.ParametersList.AddParameters(additionalParameter); } + var initExpressions = new List(); - // Build member-initializer list from block-body assignments (this.X = y) - InitializerExpressionSyntax? memberInit = null; - if (constructorDeclarationSyntax.Body is { } body && body.Statements.Count > 0) + // 1. Process base/this initializer: propagate property assignments from the + // delegated constructor so callers don't have to duplicate them in the body. + if (constructorDeclarationSyntax.Initializer is { } initializer) + { + var initializerSymbol = semanticModel.GetSymbolInfo(initializer).Symbol as IMethodSymbol; + if (initializerSymbol is not null) + { + var delegatedExprs = CollectDelegatedConstructorAssignments( + initializerSymbol, + initializer.ArgumentList.Arguments, + expressionSyntaxRewriter); + initExpressions.AddRange(delegatedExprs); + } + } + + // 2. Process this constructor's body assignments (this.X = y or X = y) + if (constructorDeclarationSyntax.Body is { } body) { - var initExpressions = new List(); foreach (var statement in body.Statements) { if (statement is ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax assignment } && assignment.IsKind(SyntaxKind.SimpleAssignmentExpression)) { - // Accept: this.X = y or X = y var targetMember = assignment.Left switch { MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax, Name: var name } => name, @@ -499,13 +512,22 @@ x is IPropertySymbol xProperty && } var rewrittenRight = (ExpressionSyntax)expressionSyntaxRewriter.Visit(assignment.Right); - initExpressions.Add( - SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, - targetMember, - rewrittenRight - ) - ); + + // Body assignments override anything set by the base/this initializer + var existing = initExpressions.FindIndex(e => + e is AssignmentExpressionSyntax ae && + ae.Left is IdentifierNameSyntax id && + id.Identifier.Text == targetMember.Identifier.Text); + var expr = SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, targetMember, rewrittenRight); + if (existing >= 0) + { + initExpressions[existing] = expr; + } + else + { + initExpressions.Add(expr); + } } else { @@ -515,17 +537,9 @@ x is IPropertySymbol xProperty && return null; } } - - if (initExpressions.Count > 0) - { - memberInit = SyntaxFactory.InitializerExpression( - SyntaxKind.ObjectInitializerExpression, - SyntaxFactory.SeparatedList(initExpressions) - ); - } } - if (memberInit is null) + if (initExpressions.Count == 0) { var diag = Diagnostic.Create(Diagnostics.RequiresBodyDefinition, constructorDeclarationSyntax.GetLocation(), memberSymbol.Name); @@ -533,17 +547,17 @@ x is IPropertySymbol xProperty && return null; } - // Use a parameterless constructor call + object initializer. - // This ensures EF Core only projects columns that are explicitly assigned - // in the constructor body — properties not listed here won't appear in SQL. - // Requirement: the DTO must expose a parameterless constructor (or init-only setters). - var emptyArgs = SyntaxFactory.ArgumentList(); + var memberInit = SyntaxFactory.InitializerExpression( + SyntaxKind.ObjectInitializerExpression, + SyntaxFactory.SeparatedList(initExpressions)); - // new ClassName() { MemberInit } + // Use a parameterless constructor + object initializer so EF Core only + // projects columns explicitly listed in the member-init bindings. + // Requirement: the DTO must have a parameterless constructor. descriptor.ExpressionBody = SyntaxFactory.ObjectCreationExpression( SyntaxFactory.Token(SyntaxKind.NewKeyword).WithTrailingTrivia(SyntaxFactory.Space), SyntaxFactory.ParseTypeName(fullTypeName), - emptyArgs, + SyntaxFactory.ArgumentList(), memberInit ); } @@ -555,6 +569,87 @@ x is IPropertySymbol xProperty && return descriptor; } + /// + /// Collects the property-assignment expressions that the delegated constructor (base/this) + /// would perform, substituting its parameters with the actual call-site argument expressions. + /// This lets the generated member-init automatically include inherited assignments without + /// requiring callers to repeat them in the body. + /// + private static IEnumerable CollectDelegatedConstructorAssignments( + IMethodSymbol delegatedCtor, + SeparatedSyntaxList callerArgs, + ExpressionSyntaxRewriter expressionSyntaxRewriter) + { + // Only process constructors whose source is available in this compilation + var syntax = delegatedCtor.DeclaringSyntaxReferences + .Select(r => r.GetSyntax()) + .OfType() + .FirstOrDefault(); + + if (syntax?.Body is null) + yield break; + + // Build a mapping: base-param-name → rewritten outer argument expression + var paramToArg = new Dictionary(); + for (var i = 0; i < callerArgs.Count && i < delegatedCtor.Parameters.Length; i++) + { + var paramName = delegatedCtor.Parameters[i].Name; + var argExpr = (ExpressionSyntax)expressionSyntaxRewriter.Visit(callerArgs[i].Expression); + paramToArg[paramName] = argExpr; + } + + // Recursively inline the delegated constructor's own base/this initializer first + if (syntax.Initializer is { } chainedInitializer) + { + // We don't have a SemanticModel here to resolve the chained symbol, so we rely + // on the simple parameter-substitution approach which covers the common patterns. + // Deep chains across assembly boundaries will silently yield nothing (graceful fallback). + } + + foreach (var statement in syntax.Body.Statements) + { + if (statement is not ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax assignment } + || !assignment.IsKind(SyntaxKind.SimpleAssignmentExpression)) + { + continue; + } + + var targetMember = assignment.Left switch + { + MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax, Name: var name } => name, + IdentifierNameSyntax ident => ident, + _ => null + }; + + if (targetMember is null) + { + continue; + } + + // Substitute base-ctor parameter references with the outer call-site expressions + var substituted = ParameterSubstitutor.Substitute(assignment.Right, paramToArg); + yield return SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, targetMember, substituted); + } + } + + /// Replaces identifier names that match base-constructor parameter names with the + /// corresponding outer argument expressions (used for base/this initializer inlining). + private sealed class ParameterSubstitutor : CSharpSyntaxRewriter + { + private readonly Dictionary _map; + + private ParameterSubstitutor(Dictionary map) => _map = map; + + public static ExpressionSyntax Substitute(ExpressionSyntax expr, Dictionary map) + => (ExpressionSyntax)new ParameterSubstitutor(map).Visit(expr); + + public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) + => _map.TryGetValue(node.Identifier.Text, out var replacement) + ? replacement.WithTriviaFrom(node) + : base.VisitIdentifierName(node); + } + private static TypeConstraintSyntax MakeTypeConstraint(string constraint) => SyntaxFactory.TypeConstraint(SyntaxFactory.IdentifierName(constraint)); } } diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs index 05d3df3..67ddeed 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs @@ -71,14 +71,11 @@ public class DerivedDto : BaseDto public DerivedDto() { } // required: EF Core uses the parameterless ctor /// - /// Note: Id must be explicitly assigned here (not just via : base(id)) - /// because the generated projection uses a parameterless constructor + member-init; - /// the base-ctor call is invisible to EF Core. + /// Id is automatically included from : base(id) — no need to repeat it here. /// [Projectable] public DerivedDto(int id, string firstName, string lastName) : base(id) { - Id = id; // explicit assignment so EF Core selects this column FullName = firstName + " " + lastName; } } From 403924e9e4b03ac9d70f02b54fd577eae4e3984a Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sat, 21 Feb 2026 23:55:39 +0100 Subject: [PATCH 4/8] Improve base constructor mapping --- ...Tests.ProjectableConstructor_WithBaseInitializer.verified.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt index a69227c..43c164c 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializer.verified.txt @@ -12,6 +12,7 @@ namespace EntityFrameworkCore.Projectables.Generated { return (int id, string name) => new global::Foo.Child() { + Id = id, Name = name }; } From 41c653bd1b87631efccb65f4f736b0e4a8dea760 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 22 Feb 2026 09:56:23 +0100 Subject: [PATCH 5/8] Supoort logic in constructors --- .../ConstructorBodyConverter.cs | 310 ++++++++++++++++++ .../ProjectableInterpreter.cs | 156 +++------ ...itializerAndIfElse.DotNet10_0.verified.txt | 5 + ...nitializerAndIfElse.DotNet9_0.verified.txt | 5 + ...rWithBaseInitializerAndIfElse.verified.txt | 5 + ...tializerExpression.DotNet10_0.verified.txt | 2 + ...itializerExpression.DotNet9_0.verified.txt | 2 + ...WithBaseInitializerExpression.verified.txt | 2 + ...torWithIfElseLogic.DotNet10_0.verified.txt | 5 + ...ctorWithIfElseLogic.DotNet9_0.verified.txt | 5 + ...ct_ConstructorWithIfElseLogic.verified.txt | 5 + ...rWithLocalVariable.DotNet10_0.verified.txt | 2 + ...orWithLocalVariable.DotNet9_0.verified.txt | 2 + ..._ConstructorWithLocalVariable.verified.txt | 2 + .../ProjectableConstructorTests.cs | 142 +++++++- ...nitializerAndIfElse.DotNet8_0.verified.txt | 20 ++ ..._WithBaseInitializerAndIfElse.verified.txt | 20 ++ ...itializerExpression.DotNet8_0.verified.txt | 20 ++ ...WithBaseInitializerExpression.verified.txt | 20 ++ ...tor_WithIfElseLogic.DotNet8_0.verified.txt | 20 ++ ...leConstructor_WithIfElseLogic.verified.txt | 20 ++ ...ructor_WithIfNoElse.DotNet8_0.verified.txt | 19 ++ ...tableConstructor_WithIfNoElse.verified.txt | 19 ++ ...r_WithLocalVariable.DotNet8_0.verified.txt | 19 ++ ...Constructor_WithLocalVariable.verified.txt | 19 ++ .../ProjectionExpressionGeneratorTests.cs | 151 +++++++++ 26 files changed, 884 insertions(+), 113 deletions(-) create mode 100644 src/EntityFrameworkCore.Projectables.Generator/ConstructorBodyConverter.cs create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerAndIfElse.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerAndIfElse.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerExpression.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerExpression.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfElseLogic.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfElseLogic.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfNoElse.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfNoElse.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithLocalVariable.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithLocalVariable.verified.txt diff --git a/src/EntityFrameworkCore.Projectables.Generator/ConstructorBodyConverter.cs b/src/EntityFrameworkCore.Projectables.Generator/ConstructorBodyConverter.cs new file mode 100644 index 0000000..6b826b8 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables.Generator/ConstructorBodyConverter.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace EntityFrameworkCore.Projectables.Generator +{ + /// + /// Converts constructor body statements into a dictionary of property-name → expression + /// pairs that are used to build a member-init expression for EF Core projections. + /// Supports simple assignments, local variable declarations, and if/else statements. + /// + public class ConstructorBodyConverter + { + private readonly SourceProductionContext _context; + + /// + /// Expression-level rewriter applied to every RHS/condition expression. + /// For the main constructor body this is the ; + /// for a delegated (base/this) constructor body it is the identity function because + /// the syntax belongs to a different compilation context and only parameter substitution + /// is needed. + /// + private readonly Func _rewrite; + + /// + /// Maps base/this constructor parameter names to the rewritten argument expressions + /// supplied at the call site. Empty when processing the main constructor body. + /// + private readonly Dictionary _paramSubstitutions; + + /// Local variable name → already-rewritten initializer expression. + private readonly Dictionary _localVariables = new(); + + /// + /// Creates a converter for the main constructor body. + /// The is applied to every expression encountered. + /// + public ConstructorBodyConverter( + SourceProductionContext context, + ExpressionSyntaxRewriter expressionRewriter) + { + _context = context; + _rewrite = expr => (ExpressionSyntax)expressionRewriter.Visit(expr); + _paramSubstitutions = new Dictionary(); + } + + /// + /// Creates a converter for a delegated (base/this) constructor body. + /// No expression-level rewriter is applied; only + /// are substituted (parameter name → call-site argument expression). + /// + public ConstructorBodyConverter( + SourceProductionContext context, + Dictionary paramSubstitutions) + { + _context = context; + _rewrite = expr => expr; // identity – base-ctor syntax lives in its own context + _paramSubstitutions = paramSubstitutions; + } + + /// + /// Tries to convert into a property-name → expression map. + /// Returns null if conversion fails (diagnostics are reported on the context). + /// + public Dictionary? TryConvertBody( + IEnumerable statements, + string memberName) + { + var assignments = new Dictionary(); + foreach (var statement in statements) + { + if (!TryProcessStatement(statement, assignments, memberName)) + { + return null; + } + } + return assignments; + } + + private bool TryProcessStatement( + StatementSyntax statement, + Dictionary assignments, + string memberName) + { + switch (statement) + { + case LocalDeclarationStatementSyntax localDecl: + return TryProcessLocalDeclaration(localDecl, memberName); + + case ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax assignment } + when assignment.IsKind(SyntaxKind.SimpleAssignmentExpression): + return TryProcessAssignment(assignment, assignments, memberName); + + case IfStatementSyntax ifStmt: + return TryProcessIfStatement(ifStmt, assignments, memberName); + + case BlockSyntax block: + return TryProcessBlock(block.Statements, assignments, memberName); + + default: + ReportUnsupported(statement, memberName, + $"Statement type '{statement.GetType().Name}' is not supported in a [Projectable] constructor body. " + + "Only assignments, local variable declarations, and if/else statements are supported."); + return false; + } + } + + private bool TryProcessLocalDeclaration(LocalDeclarationStatementSyntax localDecl, string memberName) + { + foreach (var variable in localDecl.Declaration.Variables) + { + if (variable.Initializer == null) + { + ReportUnsupported(localDecl, memberName, "Local variables must have an initializer"); + return false; + } + + var rewritten = _rewrite(variable.Initializer.Value); + rewritten = ApplySubstitutions(rewritten); + _localVariables[variable.Identifier.Text] = rewritten; + } + return true; + } + + private bool TryProcessAssignment( + AssignmentExpressionSyntax assignment, + Dictionary assignments, + string memberName) + { + var targetMember = GetTargetMember(assignment.Left); + if (targetMember is null) + { + ReportUnsupported(assignment, memberName, + $"Unsupported assignment target '{assignment.Left}'. " + + "Only 'PropertyName = ...' or 'this.PropertyName = ...' are supported."); + return false; + } + + var rewritten = _rewrite(assignment.Right); + rewritten = ApplySubstitutions(rewritten); + assignments[targetMember.Identifier.Text] = rewritten; + return true; + } + + private bool TryProcessIfStatement( + IfStatementSyntax ifStmt, + Dictionary assignments, + string memberName) + { + // Rewrite and substitute the condition + var condition = _rewrite(ifStmt.Condition); + condition = ApplySubstitutions(condition); + + // Process then-branch + var thenAssignments = new Dictionary(); + if (!TryProcessBlock(GetStatements(ifStmt.Statement), thenAssignments, memberName)) + return false; + + // Process else-branch (may be absent) + var elseAssignments = new Dictionary(); + if (ifStmt.Else != null) + { + if (!TryProcessBlock(GetStatements(ifStmt.Else.Statement), elseAssignments, memberName)) + return false; + } + + // Merge: for each property assigned in the then-branch create a ternary that + // falls back to the else-branch value, the already-accumulated value, or default. + foreach (var thenKvp in thenAssignments) + { + var prop = thenKvp.Key; + var thenExpr = thenKvp.Value; + + ExpressionSyntax elseExpr; + if (elseAssignments.TryGetValue(prop, out var elseVal)) + { + elseExpr = elseVal; + } + else if (assignments.TryGetValue(prop, out var existing)) + { + // The else-branch doesn't touch this property – keep the pre-if value. + elseExpr = existing; + } + else + { + elseExpr = DefaultLiteral(); + } + + assignments[prop] = SyntaxFactory.ConditionalExpression(condition, thenExpr, elseExpr); + } + + // For properties only in the else-branch + foreach (var elseKvp in elseAssignments) + { + var prop = elseKvp.Key; + var elseExpr = elseKvp.Value; + + if (thenAssignments.ContainsKey(prop)) + continue; // already handled above + + ExpressionSyntax thenExpr; + if (assignments.TryGetValue(prop, out var existing)) + { + thenExpr = existing; + } + else + { + thenExpr = DefaultLiteral(); + } + + assignments[prop] = SyntaxFactory.ConditionalExpression(condition, thenExpr, elseExpr); + } + + return true; + } + + private bool TryProcessBlock( + IEnumerable statements, + Dictionary assignments, + string memberName) + { + foreach (var statement in statements) + { + if (!TryProcessStatement(statement, assignments, memberName)) + return false; + } + return true; + } + + private static IEnumerable GetStatements(StatementSyntax statement) => + statement is BlockSyntax block + ? block.Statements + : new StatementSyntax[] { statement }; + + private static IdentifierNameSyntax? GetTargetMember(ExpressionSyntax left) => + left switch + { + MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax, Name: IdentifierNameSyntax name } => name, + IdentifierNameSyntax ident => ident, + _ => null + }; + + private ExpressionSyntax ApplySubstitutions(ExpressionSyntax expr) + { + if (_paramSubstitutions.Count > 0) + expr = ParameterSubstitutor.Substitute(expr, _paramSubstitutions); + if (_localVariables.Count > 0) + expr = LocalVariableSubstitutor.Substitute(expr, _localVariables); + return expr; + } + + private static ExpressionSyntax DefaultLiteral() => + SyntaxFactory.LiteralExpression( + SyntaxKind.DefaultLiteralExpression, + SyntaxFactory.Token(SyntaxKind.DefaultKeyword)); + + private void ReportUnsupported(SyntaxNode node, string memberName, string reason) + { + _context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedStatementInBlockBody, + node.GetLocation(), + memberName, + reason)); + } + + /// + /// Replaces identifier names that match base/this-constructor parameter names with the + /// corresponding outer argument expressions. + /// + internal sealed class ParameterSubstitutor : CSharpSyntaxRewriter + { + private readonly Dictionary _map; + + private ParameterSubstitutor(Dictionary map) => _map = map; + + public static ExpressionSyntax Substitute(ExpressionSyntax expr, Dictionary map) + => (ExpressionSyntax)new ParameterSubstitutor(map).Visit(expr); + + public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) + => _map.TryGetValue(node.Identifier.Text, out var replacement) + ? replacement.WithTriviaFrom(node) + : base.VisitIdentifierName(node); + } + + /// + /// Replaces local-variable identifier references with their already-rewritten + /// initializer expressions (parenthesised to preserve operator precedence). + /// + private sealed class LocalVariableSubstitutor : CSharpSyntaxRewriter + { + private readonly Dictionary _locals; + + private LocalVariableSubstitutor(Dictionary locals) => _locals = locals; + + public static ExpressionSyntax Substitute(ExpressionSyntax expr, Dictionary locals) + => (ExpressionSyntax)new LocalVariableSubstitutor(locals).Visit(expr); + + public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) + => _locals.TryGetValue(node.Identifier.Text, out var replacement) + ? SyntaxFactory.ParenthesizedExpression(replacement.WithoutTrivia()).WithTriviaFrom(node) + : base.VisitIdentifierName(node); + } + } +} + + + diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index d82f42f..3eea8b3 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -471,7 +471,8 @@ x is IPropertySymbol xProperty && descriptor.ParametersList = descriptor.ParametersList.AddParameters(additionalParameter); } - var initExpressions = new List(); + // Accumulated property-name → expression map (later converted to member-init) + var accumulatedAssignments = new Dictionary(); // 1. Process base/this initializer: propagate property assignments from the // delegated constructor so callers don't have to duplicate them in the body. @@ -480,66 +481,44 @@ x is IPropertySymbol xProperty && var initializerSymbol = semanticModel.GetSymbolInfo(initializer).Symbol as IMethodSymbol; if (initializerSymbol is not null) { - var delegatedExprs = CollectDelegatedConstructorAssignments( + var delegatedAssignments = CollectDelegatedConstructorAssignments( initializerSymbol, initializer.ArgumentList.Arguments, - expressionSyntaxRewriter); - initExpressions.AddRange(delegatedExprs); + expressionSyntaxRewriter, + context, + memberSymbol.Name); + + if (delegatedAssignments is null) + { + return null; + } + + foreach (var kvp in delegatedAssignments) + { + accumulatedAssignments[kvp.Key] = kvp.Value; + } } } - // 2. Process this constructor's body assignments (this.X = y or X = y) + // 2. Process this constructor's body (supports assignments, locals, if/else) if (constructorDeclarationSyntax.Body is { } body) { - foreach (var statement in body.Statements) - { - if (statement is ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax assignment } - && assignment.IsKind(SyntaxKind.SimpleAssignmentExpression)) - { - var targetMember = assignment.Left switch - { - MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax, Name: var name } => name, - IdentifierNameSyntax ident => ident, - _ => null - }; - - if (targetMember is null) - { - var diag = Diagnostic.Create(Diagnostics.UnsupportedStatementInBlockBody, - statement.GetLocation(), memberSymbol.Name, statement.ToString()); - context.ReportDiagnostic(diag); - return null; - } + var bodyConverter = new ConstructorBodyConverter(context, expressionSyntaxRewriter); + var bodyAssignments = bodyConverter.TryConvertBody(body.Statements, memberSymbol.Name); - var rewrittenRight = (ExpressionSyntax)expressionSyntaxRewriter.Visit(assignment.Right); + if (bodyAssignments is null) + { + return null; + } - // Body assignments override anything set by the base/this initializer - var existing = initExpressions.FindIndex(e => - e is AssignmentExpressionSyntax ae && - ae.Left is IdentifierNameSyntax id && - id.Identifier.Text == targetMember.Identifier.Text); - var expr = SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, targetMember, rewrittenRight); - if (existing >= 0) - { - initExpressions[existing] = expr; - } - else - { - initExpressions.Add(expr); - } - } - else - { - var diag = Diagnostic.Create(Diagnostics.UnsupportedStatementInBlockBody, - statement.GetLocation(), memberSymbol.Name, statement.ToString()); - context.ReportDiagnostic(diag); - return null; - } + // Body assignments override anything set by the base/this initializer + foreach (var kvp in bodyAssignments) + { + accumulatedAssignments[kvp.Key] = kvp.Value; } } - if (initExpressions.Count == 0) + if (accumulatedAssignments.Count == 0) { var diag = Diagnostic.Create(Diagnostics.RequiresBodyDefinition, constructorDeclarationSyntax.GetLocation(), memberSymbol.Name); @@ -547,6 +526,13 @@ ae.Left is IdentifierNameSyntax id && return null; } + var initExpressions = accumulatedAssignments + .Select(kvp => (ExpressionSyntax)SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName(kvp.Key), + kvp.Value)) + .ToList(); + var memberInit = SyntaxFactory.InitializerExpression( SyntaxKind.ObjectInitializerExpression, SyntaxFactory.SeparatedList(initExpressions)); @@ -572,13 +558,15 @@ ae.Left is IdentifierNameSyntax id && /// /// Collects the property-assignment expressions that the delegated constructor (base/this) /// would perform, substituting its parameters with the actual call-site argument expressions. - /// This lets the generated member-init automatically include inherited assignments without - /// requiring callers to repeat them in the body. + /// Supports if/else logic inside the delegated constructor body. + /// Returns null when an unsupported statement is encountered (diagnostics reported). /// - private static IEnumerable CollectDelegatedConstructorAssignments( + private static Dictionary? CollectDelegatedConstructorAssignments( IMethodSymbol delegatedCtor, SeparatedSyntaxList callerArgs, - ExpressionSyntaxRewriter expressionSyntaxRewriter) + ExpressionSyntaxRewriter expressionSyntaxRewriter, + SourceProductionContext context, + string memberName) { // Only process constructors whose source is available in this compilation var syntax = delegatedCtor.DeclaringSyntaxReferences @@ -587,9 +575,13 @@ private static IEnumerable CollectDelegatedConstructorAssignme .FirstOrDefault(); if (syntax?.Body is null) - yield break; + { + return new Dictionary(); + } - // Build a mapping: base-param-name → rewritten outer argument expression + // Build a mapping: base-param-name → rewritten outer argument expression. + // The argument expressions are rewritten using the *child's* ExpressionSyntaxRewriter + // so that things like null-conditional operators and type-qualified names are handled. var paramToArg = new Dictionary(); for (var i = 0; i < callerArgs.Count && i < delegatedCtor.Parameters.Length; i++) { @@ -598,56 +590,10 @@ private static IEnumerable CollectDelegatedConstructorAssignme paramToArg[paramName] = argExpr; } - // Recursively inline the delegated constructor's own base/this initializer first - if (syntax.Initializer is { } chainedInitializer) - { - // We don't have a SemanticModel here to resolve the chained symbol, so we rely - // on the simple parameter-substitution approach which covers the common patterns. - // Deep chains across assembly boundaries will silently yield nothing (graceful fallback). - } - - foreach (var statement in syntax.Body.Statements) - { - if (statement is not ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax assignment } - || !assignment.IsKind(SyntaxKind.SimpleAssignmentExpression)) - { - continue; - } - - var targetMember = assignment.Left switch - { - MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax, Name: var name } => name, - IdentifierNameSyntax ident => ident, - _ => null - }; - - if (targetMember is null) - { - continue; - } - - // Substitute base-ctor parameter references with the outer call-site expressions - var substituted = ParameterSubstitutor.Substitute(assignment.Right, paramToArg); - yield return SyntaxFactory.AssignmentExpression( - SyntaxKind.SimpleAssignmentExpression, targetMember, substituted); - } - } - - /// Replaces identifier names that match base-constructor parameter names with the - /// corresponding outer argument expressions (used for base/this initializer inlining). - private sealed class ParameterSubstitutor : CSharpSyntaxRewriter - { - private readonly Dictionary _map; - - private ParameterSubstitutor(Dictionary map) => _map = map; - - public static ExpressionSyntax Substitute(ExpressionSyntax expr, Dictionary map) - => (ExpressionSyntax)new ParameterSubstitutor(map).Visit(expr); - - public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) - => _map.TryGetValue(node.Identifier.Text, out var replacement) - ? replacement.WithTriviaFrom(node) - : base.VisitIdentifierName(node); + // Use ConstructorBodyConverter (identity rewriter + param substitutions) so that + // if/else, local variables and simple assignments in the base ctor are all handled. + var converter = new ConstructorBodyConverter(context, paramToArg); + return converter.TryConvertBody(syntax.Body.Statements, memberName); } private static TypeConstraintSyntax MakeTypeConstraint(string constraint) => SyntaxFactory.TypeConstraint(SyntaxFactory.IdentifierName(constraint)); diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt new file mode 100644 index 0000000..2f9967b --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT CASE + WHEN [p].[Id] < 0 THEN 0 + ELSE [p].[Id] +END AS [Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet9_0.verified.txt new file mode 100644 index 0000000..2f9967b --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet9_0.verified.txt @@ -0,0 +1,5 @@ +SELECT CASE + WHEN [p].[Id] < 0 THEN 0 + ELSE [p].[Id] +END AS [Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.verified.txt new file mode 100644 index 0000000..2f9967b --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.verified.txt @@ -0,0 +1,5 @@ +SELECT CASE + WHEN [p].[Id] < 0 THEN 0 + ELSE [p].[Id] +END AS [Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.DotNet10_0.verified.txt new file mode 100644 index 0000000..0c0f115 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT UPPER([p].[LastName]) AS [Code], [p].[FirstName] AS [Name] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.DotNet9_0.verified.txt new file mode 100644 index 0000000..0c0f115 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT UPPER([p].[LastName]) AS [Code], [p].[FirstName] AS [Name] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.verified.txt new file mode 100644 index 0000000..0c0f115 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerExpression.verified.txt @@ -0,0 +1,2 @@ +SELECT UPPER([p].[LastName]) AS [Code], [p].[FirstName] AS [Name] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.DotNet10_0.verified.txt new file mode 100644 index 0000000..68ebfbf --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT [p].[Id], CASE + WHEN [p].[Score] >= 90 THEN N'A' + ELSE N'B' +END AS [Grade] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.DotNet9_0.verified.txt new file mode 100644 index 0000000..68ebfbf --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.DotNet9_0.verified.txt @@ -0,0 +1,5 @@ +SELECT [p].[Id], CASE + WHEN [p].[Score] >= 90 THEN N'A' + ELSE N'B' +END AS [Grade] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.verified.txt new file mode 100644 index 0000000..68ebfbf --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithIfElseLogic.verified.txt @@ -0,0 +1,5 @@ +SELECT [p].[Id], CASE + WHEN [p].[Score] >= 90 THEN N'A' + ELSE N'B' +END AS [Grade] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt new file mode 100644 index 0000000..8fc80f2 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet9_0.verified.txt new file mode 100644 index 0000000..8fc80f2 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.verified.txt new file mode 100644 index 0000000..8fc80f2 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.verified.txt @@ -0,0 +1,2 @@ +SELECT COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs index 67ddeed..fff801a 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs @@ -12,16 +12,14 @@ namespace EntityFrameworkCore.Projectables.FunctionalTests [UsesVerify] public class ProjectableConstructorTests { - // ── Entity ────────────────────────────────────────────────────────────── public class PersonEntity { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } + public int Score { get; set; } } - // ── DTOs ───────────────────────────────────────────────────────────────── - /// DTO built from scalar entity fields. public class PersonSummaryDto { @@ -54,8 +52,6 @@ public PersonFromEntityDto(PersonEntity entity) } } - // ── Base / derived DTO ──────────────────────────────────────────────────── - public class BaseDto { public int Id { get; set; } @@ -80,8 +76,6 @@ public DerivedDto(int id, string firstName, string lastName) : base(id) } } - // ── Overloaded constructors ─────────────────────────────────────────────── - public class PersonOverloadedDto { public int Id { get; set; } @@ -128,7 +122,95 @@ public PersonPartialDto(int id, string firstName, string lastName) } } - // ── Tests ───────────────────────────────────────────────────────────────── + /// DTO with if/else logic in the constructor body. + public class PersonGradeDto + { + public int Id { get; set; } + public string Grade { get; set; } + + public PersonGradeDto() { } + + [Projectable] + public PersonGradeDto(int id, int score) + { + Id = id; + if (score >= 90) + { + Grade = "A"; + } + else + { + Grade = "B"; + } + } + } + + /// DTO using a local variable in the constructor body. + public class PersonLocalVarDto + { + public string FullName { get; set; } + + public PersonLocalVarDto() { } + + [Projectable] + public PersonLocalVarDto(string first, string last) + { + var full = first + " " + last; + FullName = full; + } + } + + public class PersonBaseWithExprDto + { + public string Code { get; set; } + + public PersonBaseWithExprDto() { } + public PersonBaseWithExprDto(string code) { Code = code; } + } + + public class PersonDerivedWithExprDto : PersonBaseWithExprDto + { + public string Name { get; set; } + + public PersonDerivedWithExprDto() { } + + [Projectable] + public PersonDerivedWithExprDto(string name, string rawCode) : base(rawCode.ToUpper()) + { + Name = name; + } + } + + public class PersonBaseWithLogicDto + { + public int Id { get; set; } + + public PersonBaseWithLogicDto() { } + public PersonBaseWithLogicDto(int id) + { + if (id < 0) + { + Id = 0; + } + else + { + Id = id; + } + } + } + + public class PersonDerivedWithBaseLogicDto : PersonBaseWithLogicDto + { + public string FullName { get; set; } + + public PersonDerivedWithBaseLogicDto() { } + + [Projectable] + public PersonDerivedWithBaseLogicDto(int id, string firstName, string lastName) : base(id) + { + FullName = firstName + " " + lastName; + } + } [Fact] public Task Select_ScalarFieldsToDto() @@ -204,6 +286,50 @@ public Task Select_UnassignedPropertyNotInQuery() return Verifier.Verify(sql); } + + [Fact] + public Task Select_ConstructorWithIfElseLogic() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonGradeDto(p.Id, p.Score)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_ConstructorWithLocalVariable() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonLocalVarDto(p.FirstName, p.LastName)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_ConstructorWithBaseInitializerExpression() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonDerivedWithExprDto(p.FirstName, p.LastName)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_ConstructorWithBaseInitializerAndIfElse() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonDerivedWithBaseLogicDto(p.Id, p.FirstName, p.LastName)); + + return Verifier.Verify(query.ToQueryString()); + } } } diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerAndIfElse.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerAndIfElse.DotNet8_0.verified.txt new file mode 100644 index 0000000..640a785 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerAndIfElse.DotNet8_0.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_int_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int id, string name) => new global::Foo.Child() + { + Id = id < 0 ? 0 : id, + Name = name + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerAndIfElse.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerAndIfElse.verified.txt new file mode 100644 index 0000000..640a785 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerAndIfElse.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_int_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int id, string name) => new global::Foo.Child() + { + Id = id < 0 ? 0 : id, + Name = name + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerExpression.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerExpression.DotNet8_0.verified.txt new file mode 100644 index 0000000..4079aac --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerExpression.DotNet8_0.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_string_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string name, string rawCode) => new global::Foo.Child() + { + Code = rawCode.ToUpper(), + Name = name + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerExpression.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerExpression.verified.txt new file mode 100644 index 0000000..4079aac --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithBaseInitializerExpression.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_string_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string name, string rawCode) => new global::Foo.Child() + { + Code = rawCode.ToUpper(), + Name = name + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfElseLogic.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfElseLogic.DotNet8_0.verified.txt new file mode 100644 index 0000000..09491e5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfElseLogic.DotNet8_0.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_int + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int score) => new global::Foo.PersonDto() + { + Score = score, + Label = score >= 90 ? "A" : "B" + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfElseLogic.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfElseLogic.verified.txt new file mode 100644 index 0000000..09491e5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfElseLogic.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_int + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int score) => new global::Foo.PersonDto() + { + Score = score, + Label = score >= 90 ? "A" : "B" + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfNoElse.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfNoElse.DotNet8_0.verified.txt new file mode 100644 index 0000000..b485526 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfNoElse.DotNet8_0.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_int + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int score) => new global::Foo.PersonDto() + { + Label = score >= 90 ? "A" : "none" + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfNoElse.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfNoElse.verified.txt new file mode 100644 index 0000000..b485526 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithIfNoElse.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_int + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int score) => new global::Foo.PersonDto() + { + Label = score >= 90 ? "A" : "none" + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithLocalVariable.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithLocalVariable.DotNet8_0.verified.txt new file mode 100644 index 0000000..ac1d676 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithLocalVariable.DotNet8_0.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string first, string last) => new global::Foo.PersonDto() + { + FullName = (first + " " + last) + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithLocalVariable.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithLocalVariable.verified.txt new file mode 100644 index 0000000..ac1d676 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithLocalVariable.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string first, string last) => new global::Foo.PersonDto() + { + FullName = (first + " " + last) + }; + } + } +} \ 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 3035641..f066a39 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs @@ -3526,6 +3526,157 @@ public PersonDto(NamePart first, NamePart last) { return Verifier.Verify(result.GeneratedTrees[0].ToString()); } + [Fact] + public Task ProjectableConstructor_WithIfElseLogic() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string Label { get; set; } + public int Score { get; set; } + + [Projectable] + public PersonDto(int score) { + Score = score; + if (score >= 90) { + Label = ""A""; + } else { + Label = ""B""; + } + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_WithLocalVariable() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string FullName { get; set; } + + [Projectable] + public PersonDto(string first, string last) { + var full = first + "" "" + last; + FullName = full; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_WithBaseInitializerExpression() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Base { + public string Code { get; set; } + public Base(string code) { Code = code; } + } + + class Child : Base { + public string Name { get; set; } + + [Projectable] + public Child(string name, string rawCode) : base(rawCode.ToUpper()) { + Name = name; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_WithBaseInitializerAndIfElse() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Base { + public int Id { get; set; } + public Base(int id) { + if (id < 0) { + Id = 0; + } else { + Id = id; + } + } + } + + class Child : Base { + public string Name { get; set; } + + [Projectable] + public Child(int id, string name) : base(id) { + Name = name; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_WithIfNoElse() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string Label { get; set; } + + [Projectable] + public PersonDto(int score) { + Label = ""none""; + if (score >= 90) { + Label = ""A""; + } + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + #region Helpers Compilation CreateCompilation(string source, bool expectedToCompile = true) From d84a1219c13dcb96ee1170472ebb77385cbf0c38 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 22 Feb 2026 10:31:41 +0100 Subject: [PATCH 6/8] Improve ctor body generation --- .../ConstructorBodyConverter.cs | 252 ++++++++++++------ .../ProjectableInterpreter.cs | 8 +- ...pertyInDerivedBody.DotNet10_0.verified.txt | 2 + ...opertyInDerivedBody.DotNet8_0.verified.txt | 2 + ...opertyInDerivedBody.DotNet9_0.verified.txt | 2 + ...cingBasePropertyInDerivedBody.verified.txt | 2 + ...lyAssignedProperty.DotNet10_0.verified.txt | 2 + ...slyAssignedProperty.DotNet8_0.verified.txt | 2 + ...slyAssignedProperty.DotNet9_0.verified.txt | 2 + ...ingPreviouslyAssignedProperty.verified.txt | 2 + ...gStaticConstMember.DotNet10_0.verified.txt | 2 + ...ngStaticConstMember.DotNet8_0.verified.txt | 2 + ...ngStaticConstMember.DotNet9_0.verified.txt | 2 + ...rReferencingStaticConstMember.verified.txt | 2 + .../ProjectableConstructorTests.cs | 91 +++++++ ...opertyInDerivedBody.DotNet8_0.verified.txt | 20 ++ ...cingBasePropertyInDerivedBody.verified.txt | 20 ++ ...yAssignedInBaseCtor.DotNet8_0.verified.txt | 21 ++ ...gPreviouslyAssignedInBaseCtor.verified.txt | 21 ++ ...slyAssignedProperty.DotNet8_0.verified.txt | 21 ++ ...ingPreviouslyAssignedProperty.verified.txt | 21 ++ ...ngStaticConstMember.DotNet8_0.verified.txt | 19 ++ ..._ReferencingStaticConstMember.verified.txt | 19 ++ .../ProjectionExpressionGeneratorTests.cs | 119 +++++++++ 24 files changed, 577 insertions(+), 79 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingBasePropertyInDerivedBody.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingBasePropertyInDerivedBody.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedInBaseCtor.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedInBaseCtor.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedProperty.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingStaticConstMember.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingStaticConstMember.verified.txt diff --git a/src/EntityFrameworkCore.Projectables.Generator/ConstructorBodyConverter.cs b/src/EntityFrameworkCore.Projectables.Generator/ConstructorBodyConverter.cs index 6b826b8..15618b0 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ConstructorBodyConverter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ConstructorBodyConverter.cs @@ -11,33 +11,17 @@ namespace EntityFrameworkCore.Projectables.Generator /// Converts constructor body statements into a dictionary of property-name → expression /// pairs that are used to build a member-init expression for EF Core projections. /// Supports simple assignments, local variable declarations, and if/else statements. + /// Previously-assigned properties (including those from a delegated base/this ctor) are + /// inlined when referenced in subsequent assignments. /// public class ConstructorBodyConverter { private readonly SourceProductionContext _context; - - /// - /// Expression-level rewriter applied to every RHS/condition expression. - /// For the main constructor body this is the ; - /// for a delegated (base/this) constructor body it is the identity function because - /// the syntax belongs to a different compilation context and only parameter substitution - /// is needed. - /// private readonly Func _rewrite; - - /// - /// Maps base/this constructor parameter names to the rewritten argument expressions - /// supplied at the call site. Empty when processing the main constructor body. - /// private readonly Dictionary _paramSubstitutions; - - /// Local variable name → already-rewritten initializer expression. private readonly Dictionary _localVariables = new(); - /// - /// Creates a converter for the main constructor body. - /// The is applied to every expression encountered. - /// + /// Creates a converter for the main constructor body. public ConstructorBodyConverter( SourceProductionContext context, ExpressionSyntaxRewriter expressionRewriter) @@ -47,58 +31,68 @@ public ConstructorBodyConverter( _paramSubstitutions = new Dictionary(); } - /// - /// Creates a converter for a delegated (base/this) constructor body. - /// No expression-level rewriter is applied; only - /// are substituted (parameter name → call-site argument expression). - /// + /// Creates a converter for a delegated (base/this) constructor body. public ConstructorBodyConverter( SourceProductionContext context, Dictionary paramSubstitutions) { _context = context; - _rewrite = expr => expr; // identity – base-ctor syntax lives in its own context + _rewrite = expr => expr; _paramSubstitutions = paramSubstitutions; } - - /// - /// Tries to convert into a property-name → expression map. - /// Returns null if conversion fails (diagnostics are reported on the context). - /// + public Dictionary? TryConvertBody( IEnumerable statements, - string memberName) + string memberName, + IReadOnlyDictionary? initialContext = null) { var assignments = new Dictionary(); + if (!TryProcessBlock(statements, assignments, memberName, outerContext: initialContext)) + { + return null; + } + + return assignments; + } + + private bool TryProcessBlock( + IEnumerable statements, + Dictionary assignments, + string memberName, + IReadOnlyDictionary? outerContext) + { foreach (var statement in statements) { - if (!TryProcessStatement(statement, assignments, memberName)) + // Everything accumulated so far (from outer scope + this block) is visible. + var visible = BuildVisible(outerContext, assignments); + if (!TryProcessStatement(statement, assignments, memberName, visible)) { - return null; + return false; } } - return assignments; + return true; } private bool TryProcessStatement( StatementSyntax statement, Dictionary assignments, - string memberName) + string memberName, + IReadOnlyDictionary? visibleContext) { switch (statement) { case LocalDeclarationStatementSyntax localDecl: - return TryProcessLocalDeclaration(localDecl, memberName); + return TryProcessLocalDeclaration(localDecl, memberName, visibleContext); case ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax assignment } when assignment.IsKind(SyntaxKind.SimpleAssignmentExpression): - return TryProcessAssignment(assignment, assignments, memberName); + return TryProcessAssignment(assignment, assignments, memberName, visibleContext); case IfStatementSyntax ifStmt: - return TryProcessIfStatement(ifStmt, assignments, memberName); + return TryProcessIfStatement(ifStmt, assignments, memberName, visibleContext); case BlockSyntax block: - return TryProcessBlock(block.Statements, assignments, memberName); + return TryProcessBlock(block.Statements, assignments, memberName, visibleContext); default: ReportUnsupported(statement, memberName, @@ -108,7 +102,10 @@ when assignment.IsKind(SyntaxKind.SimpleAssignmentExpression): } } - private bool TryProcessLocalDeclaration(LocalDeclarationStatementSyntax localDecl, string memberName) + private bool TryProcessLocalDeclaration( + LocalDeclarationStatementSyntax localDecl, + string memberName, + IReadOnlyDictionary? visibleContext) { foreach (var variable in localDecl.Declaration.Variables) { @@ -119,7 +116,7 @@ private bool TryProcessLocalDeclaration(LocalDeclarationStatementSyntax localDec } var rewritten = _rewrite(variable.Initializer.Value); - rewritten = ApplySubstitutions(rewritten); + rewritten = ApplySubstitutions(rewritten, visibleContext); _localVariables[variable.Identifier.Text] = rewritten; } return true; @@ -128,7 +125,8 @@ private bool TryProcessLocalDeclaration(LocalDeclarationStatementSyntax localDec private bool TryProcessAssignment( AssignmentExpressionSyntax assignment, Dictionary assignments, - string memberName) + string memberName, + IReadOnlyDictionary? visibleContext) { var targetMember = GetTargetMember(assignment.Left); if (targetMember is null) @@ -140,7 +138,7 @@ private bool TryProcessAssignment( } var rewritten = _rewrite(assignment.Right); - rewritten = ApplySubstitutions(rewritten); + rewritten = ApplySubstitutions(rewritten, visibleContext); assignments[targetMember.Identifier.Text] = rewritten; return true; } @@ -148,27 +146,28 @@ private bool TryProcessAssignment( private bool TryProcessIfStatement( IfStatementSyntax ifStmt, Dictionary assignments, - string memberName) + string memberName, + IReadOnlyDictionary? visibleContext) { - // Rewrite and substitute the condition var condition = _rewrite(ifStmt.Condition); - condition = ApplySubstitutions(condition); + condition = ApplySubstitutions(condition, visibleContext); - // Process then-branch + // Each branch starts empty but can see the pre-if accumulated props (visibleContext). var thenAssignments = new Dictionary(); - if (!TryProcessBlock(GetStatements(ifStmt.Statement), thenAssignments, memberName)) + if (!TryProcessBlock(GetStatements(ifStmt.Statement), thenAssignments, memberName, visibleContext)) + { return false; + } - // Process else-branch (may be absent) var elseAssignments = new Dictionary(); if (ifStmt.Else != null) { - if (!TryProcessBlock(GetStatements(ifStmt.Else.Statement), elseAssignments, memberName)) + if (!TryProcessBlock(GetStatements(ifStmt.Else.Statement), elseAssignments, memberName, visibleContext)) + { return false; + } } - // Merge: for each property assigned in the then-branch create a ternary that - // falls back to the else-branch value, the already-accumulated value, or default. foreach (var thenKvp in thenAssignments) { var prop = thenKvp.Key; @@ -181,7 +180,6 @@ private bool TryProcessIfStatement( } else if (assignments.TryGetValue(prop, out var existing)) { - // The else-branch doesn't touch this property – keep the pre-if value. elseExpr = existing; } else @@ -192,14 +190,15 @@ private bool TryProcessIfStatement( assignments[prop] = SyntaxFactory.ConditionalExpression(condition, thenExpr, elseExpr); } - // For properties only in the else-branch foreach (var elseKvp in elseAssignments) { var prop = elseKvp.Key; var elseExpr = elseKvp.Value; if (thenAssignments.ContainsKey(prop)) - continue; // already handled above + { + continue; + } ExpressionSyntax thenExpr; if (assignments.TryGetValue(prop, out var existing)) @@ -217,19 +216,6 @@ private bool TryProcessIfStatement( return true; } - private bool TryProcessBlock( - IEnumerable statements, - Dictionary assignments, - string memberName) - { - foreach (var statement in statements) - { - if (!TryProcessStatement(statement, assignments, memberName)) - return false; - } - return true; - } - private static IEnumerable GetStatements(StatementSyntax statement) => statement is BlockSyntax block ? block.Statements @@ -243,12 +229,67 @@ statement is BlockSyntax block _ => null }; - private ExpressionSyntax ApplySubstitutions(ExpressionSyntax expr) + /// + /// Merges outer (parent scope) and local (current block) accumulated dictionaries + /// into a single read-only view for use as a substitution context. + /// Local entries take priority over outer entries. + /// Returns null when both are empty (avoids unnecessary allocations). + /// + private static IReadOnlyDictionary? BuildVisible( + IReadOnlyDictionary? outer, + Dictionary local) + { + bool outerEmpty = outer == null || outer.Count == 0; + var localEmpty = local.Count == 0; + + if (outerEmpty && localEmpty) + { + return null; + } + + if (outerEmpty) + { + return local; + } + + if (localEmpty) + { + return outer; + } + + var merged = new Dictionary(); + foreach (var kvp in outer!) + { + merged[kvp.Key] = kvp.Value; + } + + foreach (var kvp in local) + { + merged[kvp.Key] = kvp.Value; + } + + return merged; + } + + private ExpressionSyntax ApplySubstitutions( + ExpressionSyntax expr, + IReadOnlyDictionary? visibleContext) { if (_paramSubstitutions.Count > 0) + { expr = ParameterSubstitutor.Substitute(expr, _paramSubstitutions); + } + if (_localVariables.Count > 0) + { expr = LocalVariableSubstitutor.Substitute(expr, _localVariables); + } + + if (visibleContext != null && visibleContext.Count > 0) + { + expr = AssignedPropertySubstitutor.Substitute(expr, visibleContext); + } + return expr; } @@ -267,13 +308,12 @@ private void ReportUnsupported(SyntaxNode node, string memberName, string reason } /// - /// Replaces identifier names that match base/this-constructor parameter names with the - /// corresponding outer argument expressions. + /// Replaces parameter-name identifier references with call-site argument expressions + /// (used when inlining a delegated base/this constructor body). /// internal sealed class ParameterSubstitutor : CSharpSyntaxRewriter { private readonly Dictionary _map; - private ParameterSubstitutor(Dictionary map) => _map = map; public static ExpressionSyntax Substitute(ExpressionSyntax expr, Dictionary map) @@ -286,13 +326,12 @@ public static ExpressionSyntax Substitute(ExpressionSyntax expr, Dictionary - /// Replaces local-variable identifier references with their already-rewritten - /// initializer expressions (parenthesised to preserve operator precedence). + /// Replaces local-variable identifier references with their inlined (parenthesised) + /// initializer expressions. /// private sealed class LocalVariableSubstitutor : CSharpSyntaxRewriter { private readonly Dictionary _locals; - private LocalVariableSubstitutor(Dictionary locals) => _locals = locals; public static ExpressionSyntax Substitute(ExpressionSyntax expr, Dictionary locals) @@ -303,8 +342,67 @@ public static ExpressionSyntax Substitute(ExpressionSyntax expr, Dictionary + /// Replaces references to previously-assigned properties with the expression that was + /// assigned to them, so that EF Core sees a fully-inlined projection. + /// + /// Handles two syntactic forms: + /// + /// @this.PropName — produced by for + /// instance-member references in the main constructor body. + /// Bare PropName identifier — appears in delegated (base/this) constructor + /// bodies where the identity rewriter is used. + /// + /// + /// + private sealed class AssignedPropertySubstitutor : CSharpSyntaxRewriter + { + private readonly IReadOnlyDictionary _accumulated; + + private AssignedPropertySubstitutor(IReadOnlyDictionary accumulated) + => _accumulated = accumulated; + public static ExpressionSyntax Substitute( + ExpressionSyntax expr, + IReadOnlyDictionary accumulated) + => (ExpressionSyntax)new AssignedPropertySubstitutor(accumulated).Visit(expr); + + // Catches @this.PropName → inline the accumulated expression for PropName. + public override SyntaxNode? VisitMemberAccessExpression(MemberAccessExpressionSyntax node) + { + if (node.Expression is IdentifierNameSyntax thisRef && + (thisRef.Identifier.Text == "@this" || thisRef.Identifier.ValueText == "this") && + node.Name is IdentifierNameSyntax propName && + _accumulated.TryGetValue(propName.Identifier.Text, out var replacement)) + { + return SyntaxFactory.ParenthesizedExpression(replacement.WithoutTrivia()) + .WithTriviaFrom(node); + } + + return base.VisitMemberAccessExpression(node); + } + // Catches bare PropName → inline accumulated expression (delegated ctor case). + // Params and locals have already been substituted before this runs, so any remaining + // bare identifier that matches an accumulated property key is a property reference. + public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) + { + // Do not substitute special identifiers (@this, type keywords, etc.) + var text = node.Identifier.Text; + if (text.StartsWith("@") || text == "default" || text == "null" || text == "true" || text == "false") + { + return base.VisitIdentifierName(node); + } + + if (_accumulated.TryGetValue(text, out var replacement)) + { + return SyntaxFactory.ParenthesizedExpression(replacement.WithoutTrivia()) + .WithTriviaFrom(node); + } + + return base.VisitIdentifierName(node); + } + } + } +} diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index 3eea8b3..755e022 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -500,11 +500,15 @@ x is IPropertySymbol xProperty && } } - // 2. Process this constructor's body (supports assignments, locals, if/else) + // 2. Process this constructor's body (supports assignments, locals, if/else). + // Pass the already-accumulated base/this initializer assignments as the initial + // visible context so that references to those properties are correctly inlined. if (constructorDeclarationSyntax.Body is { } body) { var bodyConverter = new ConstructorBodyConverter(context, expressionSyntaxRewriter); - var bodyAssignments = bodyConverter.TryConvertBody(body.Statements, memberSymbol.Name); + IReadOnlyDictionary? initialCtx = + accumulatedAssignments.Count > 0 ? accumulatedAssignments : null; + var bodyAssignments = bodyConverter.TryConvertBody(body.Statements, memberSymbol.Name, initialCtx); if (bodyAssignments is null) { diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt new file mode 100644 index 0000000..6a9d698 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName] AS [Code], N'[' + COALESCE([p].[FirstName], N'') + N']' AS [Label] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet8_0.verified.txt new file mode 100644 index 0000000..6a9d698 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName] AS [Code], N'[' + COALESCE([p].[FirstName], N'') + N']' AS [Label] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet9_0.verified.txt new file mode 100644 index 0000000..6a9d698 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName] AS [Code], N'[' + COALESCE([p].[FirstName], N'') + N']' AS [Label] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.verified.txt new file mode 100644 index 0000000..6a9d698 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName] AS [Code], N'[' + COALESCE([p].[FirstName], N'') + N']' AS [Label] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt new file mode 100644 index 0000000..cccd5bb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt new file mode 100644 index 0000000..cccd5bb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet9_0.verified.txt new file mode 100644 index 0000000..cccd5bb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.verified.txt new file mode 100644 index 0000000..cccd5bb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt new file mode 100644 index 0000000..2752cb7 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT COALESCE([p].[FirstName], N'') + N' - ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet8_0.verified.txt new file mode 100644 index 0000000..2752cb7 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT COALESCE([p].[FirstName], N'') + N' - ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet9_0.verified.txt new file mode 100644 index 0000000..2752cb7 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT COALESCE([p].[FirstName], N'') + N' - ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.verified.txt new file mode 100644 index 0000000..2752cb7 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.verified.txt @@ -0,0 +1,2 @@ +SELECT COALESCE([p].[FirstName], N'') + N' - ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs index fff801a..4d366ef 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs @@ -212,6 +212,64 @@ public PersonDerivedWithBaseLogicDto(int id, string firstName, string lastName) } } + // ── Referencing previously-assigned property ────────────────────────────── + + public class PersonWithCompositeDto + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string FullName { get; set; } + + public PersonWithCompositeDto() { } + + [Projectable] + public PersonWithCompositeDto(string firstName, string lastName) + { + FirstName = firstName; + LastName = lastName; + FullName = FirstName + " " + LastName; // references previously-assigned props + } + } + + // ── Referencing base property in derived body ───────────────────────────── + + public class PersonBaseCodeDto + { + public string Code { get; set; } + public PersonBaseCodeDto() { } + public PersonBaseCodeDto(string code) { Code = code; } + } + + public class PersonDerivedLabelDto : PersonBaseCodeDto + { + public string Label { get; set; } + + public PersonDerivedLabelDto() { } + + [Projectable] + public PersonDerivedLabelDto(string code) : base(code) + { + Label = "[" + Code + "]"; // Code was set by base ctor → should inline it + } + } + + // ── Static / const member ───────────────────────────────────────────────── + + public class PersonWithConstSeparatorDto + { + internal const string Separator = " - "; + + public string FullName { get; set; } + + public PersonWithConstSeparatorDto() { } + + [Projectable] + public PersonWithConstSeparatorDto(string firstName, string lastName) + { + FullName = firstName + Separator + lastName; + } + } + [Fact] public Task Select_ScalarFieldsToDto() { @@ -330,6 +388,39 @@ public Task Select_ConstructorWithBaseInitializerAndIfElse() return Verifier.Verify(query.ToQueryString()); } + + [Fact] + public Task Select_ConstructorReferencingPreviouslyAssignedProperty() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonWithCompositeDto(p.FirstName, p.LastName)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_ConstructorReferencingBasePropertyInDerivedBody() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonDerivedLabelDto(p.FirstName)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_ConstructorReferencingStaticConstMember() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonWithConstSeparatorDto(p.FirstName, p.LastName)); + + return Verifier.Verify(query.ToQueryString()); + } } } diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingBasePropertyInDerivedBody.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingBasePropertyInDerivedBody.DotNet8_0.verified.txt new file mode 100644 index 0000000..c2beff8 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingBasePropertyInDerivedBody.DotNet8_0.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string code) => new global::Foo.Child() + { + Code = code, + Label = "[" + (code) + "]" + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingBasePropertyInDerivedBody.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingBasePropertyInDerivedBody.verified.txt new file mode 100644 index 0000000..c2beff8 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingBasePropertyInDerivedBody.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string code) => new global::Foo.Child() + { + Code = code, + Label = "[" + (code) + "]" + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedInBaseCtor.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedInBaseCtor.DotNet8_0.verified.txt new file mode 100644 index 0000000..ea81d87 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedInBaseCtor.DotNet8_0.verified.txt @@ -0,0 +1,21 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_int_P1_int + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int a, int b) => new global::Foo.Child() + { + X = a, + Y = a + b, + Sum = (a) + (a + b) + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedInBaseCtor.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedInBaseCtor.verified.txt new file mode 100644 index 0000000..ea81d87 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedInBaseCtor.verified.txt @@ -0,0 +1,21 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_int_P1_int + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int a, int b) => new global::Foo.Child() + { + X = a, + Y = a + b, + Sum = (a) + (a + b) + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt new file mode 100644 index 0000000..ab88351 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt @@ -0,0 +1,21 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string firstName, string lastName) => new global::Foo.PersonDto() + { + FirstName = firstName, + LastName = lastName, + FullName = (firstName) + " " + (lastName) + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedProperty.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedProperty.verified.txt new file mode 100644 index 0000000..ab88351 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingPreviouslyAssignedProperty.verified.txt @@ -0,0 +1,21 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string firstName, string lastName) => new global::Foo.PersonDto() + { + FirstName = firstName, + LastName = lastName, + FullName = (firstName) + " " + (lastName) + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingStaticConstMember.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingStaticConstMember.DotNet8_0.verified.txt new file mode 100644 index 0000000..1bd1677 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingStaticConstMember.DotNet8_0.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string first, string last) => new global::Foo.PersonDto() + { + FullName = first + global::Foo.PersonDto.Separator + last + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingStaticConstMember.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingStaticConstMember.verified.txt new file mode 100644 index 0000000..1bd1677 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ReferencingStaticConstMember.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string first, string last) => new global::Foo.PersonDto() + { + FullName = first + global::Foo.PersonDto.Separator + last + }; + } + } +} \ 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 f066a39..128218c 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs @@ -3677,6 +3677,125 @@ public PersonDto(int score) { return Verifier.Verify(result.GeneratedTrees[0].ToString()); } + [Fact] + public Task ProjectableConstructor_ReferencingPreviouslyAssignedProperty() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string FirstName { get; set; } + public string LastName { get; set; } + public string FullName { get; set; } + + [Projectable] + public PersonDto(string firstName, string lastName) { + FirstName = firstName; + LastName = lastName; + FullName = FirstName + "" "" + LastName; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_ReferencingBasePropertyInDerivedBody() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Base { + public string Code { get; set; } + public Base(string code) { Code = code; } + } + + class Child : Base { + public string Label { get; set; } + + [Projectable] + public Child(string code) : base(code) { + Label = ""["" + Code + ""]""; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_ReferencingStaticConstMember() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + internal const string Separator = "" - ""; + public string FullName { get; set; } + + [Projectable] + public PersonDto(string first, string last) { + FullName = first + Separator + last; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_ReferencingPreviouslyAssignedInBaseCtor() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Base { + public int X { get; set; } + public int Y { get; set; } + public Base(int x, int y) { + X = x; + Y = x + y; // Y depends on X's assigned value (x) + } + } + + class Child : Base { + public int Sum { get; set; } + + [Projectable] + public Child(int a, int b) : base(a, b) { + Sum = X + Y; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + #region Helpers Compilation CreateCompilation(string source, bool expectedToCompile = true) From 2b7159f68b5911095457a17c2901c8feef7897e8 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 22 Feb 2026 16:03:22 +0100 Subject: [PATCH 7/8] Enhance delegated constructor handling with recursive base/this initializer support --- .../ProjectableInterpreter.cs | 94 +++++++++- ...ChainedThisAndBase.DotNet10_0.verified.txt | 2 + ..._ChainedThisAndBase.DotNet8_0.verified.txt | 2 + ..._ChainedThisAndBase.DotNet9_0.verified.txt | 2 + ...isOverload_ChainedThisAndBase.verified.txt | 2 + ...oad_SimpleDelegate.DotNet10_0.verified.txt | 2 + ...load_SimpleDelegate.DotNet8_0.verified.txt | 2 + ...load_SimpleDelegate.DotNet9_0.verified.txt | 2 + ...t_ThisOverload_SimpleDelegate.verified.txt | 2 + ...odyAfterDelegation.DotNet10_0.verified.txt | 2 + ...BodyAfterDelegation.DotNet8_0.verified.txt | 2 + ...BodyAfterDelegation.DotNet9_0.verified.txt | 2 + ...rload_WithBodyAfterDelegation.verified.txt | 2 + ...hIfElseInDelegated.DotNet10_0.verified.txt | 5 + ...thIfElseInDelegated.DotNet8_0.verified.txt | 5 + ...thIfElseInDelegated.DotNet9_0.verified.txt | 5 + ...verload_WithIfElseInDelegated.verified.txt | 5 + .../ProjectableConstructorTests.cs | 141 ++++++++++++++ ..._ChainedThisAndBase.DotNet8_0.verified.txt | 20 ++ ...nitializer_ChainedThisAndBase.verified.txt | 20 ++ ...slyAssignedProperty.DotNet8_0.verified.txt | 21 +++ ...ingPreviouslyAssignedProperty.verified.txt | 21 +++ ...izer_SimpleOverload.DotNet8_0.verified.txt | 20 ++ ...hisInitializer_SimpleOverload.verified.txt | 20 ++ ...lizer_WithBodyAfter.DotNet8_0.verified.txt | 21 +++ ...ThisInitializer_WithBodyAfter.verified.txt | 21 +++ ...thIfElseInDelegated.DotNet8_0.verified.txt | 20 ++ ...ializer_WithIfElseInDelegated.verified.txt | 20 ++ .../ProjectionExpressionGeneratorTests.cs | 173 ++++++++++++++++++ 29 files changed, 647 insertions(+), 9 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ChainedThisAndBase.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ChainedThisAndBase.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ReferencingPreviouslyAssignedProperty.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_SimpleOverload.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_SimpleOverload.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithBodyAfter.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithBodyAfter.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithIfElseInDelegated.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithIfElseInDelegated.verified.txt diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index 755e022..9e1d922 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -562,7 +562,8 @@ x is IPropertySymbol xProperty && /// /// Collects the property-assignment expressions that the delegated constructor (base/this) /// would perform, substituting its parameters with the actual call-site argument expressions. - /// Supports if/else logic inside the delegated constructor body. + /// Supports if/else logic inside the delegated constructor body, and follows the chain of + /// base/this initializers recursively. /// Returns null when an unsupported statement is encountered (diagnostics reported). /// private static Dictionary? CollectDelegatedConstructorAssignments( @@ -570,7 +571,8 @@ x is IPropertySymbol xProperty && SeparatedSyntaxList callerArgs, ExpressionSyntaxRewriter expressionSyntaxRewriter, SourceProductionContext context, - string memberName) + string memberName, + bool argsAlreadyRewritten = false) { // Only process constructors whose source is available in this compilation var syntax = delegatedCtor.DeclaringSyntaxReferences @@ -578,26 +580,100 @@ x is IPropertySymbol xProperty && .OfType() .FirstOrDefault(); - if (syntax?.Body is null) + if (syntax is null) { return new Dictionary(); } - // Build a mapping: base-param-name → rewritten outer argument expression. - // The argument expressions are rewritten using the *child's* ExpressionSyntaxRewriter - // so that things like null-conditional operators and type-qualified names are handled. + // Build a mapping: delegated-param-name → caller argument expression. + // First-level args come from the original syntax tree and must be visited by the + // ExpressionSyntaxRewriter. Recursive-level args are already-substituted detached + // nodes and must NOT be visited (doing so throws "node not in syntax tree"). var paramToArg = new Dictionary(); for (var i = 0; i < callerArgs.Count && i < delegatedCtor.Parameters.Length; i++) { var paramName = delegatedCtor.Parameters[i].Name; - var argExpr = (ExpressionSyntax)expressionSyntaxRewriter.Visit(callerArgs[i].Expression); + var argExpr = argsAlreadyRewritten + ? callerArgs[i].Expression + : (ExpressionSyntax)expressionSyntaxRewriter.Visit(callerArgs[i].Expression); paramToArg[paramName] = argExpr; } + // The accumulated assignments start from the delegated ctor's own initializer (if any), + // so that base/this chains are followed recursively. + var accumulated = new Dictionary(); + + if (syntax.Initializer is { } delegatedInitializer) + { + // The delegated ctor's initializer is part of the original syntax tree, + // so we can safely use the semantic model to resolve its symbol. + var semanticModel = expressionSyntaxRewriter.GetSemanticModel(); + var delegatedInitializerSymbol = + semanticModel.GetSymbolInfo(delegatedInitializer).Symbol as IMethodSymbol; + + if (delegatedInitializerSymbol is not null) + { + // Substitute the delegated ctor's initializer arguments using our paramToArg map, + // so that e.g. `: base(id)` becomes `: base()`. + var substitutedInitArgs = SubstituteArguments( + delegatedInitializer.ArgumentList.Arguments, paramToArg); + + var chainedAssignments = CollectDelegatedConstructorAssignments( + delegatedInitializerSymbol, + substitutedInitArgs, + expressionSyntaxRewriter, + context, + memberName, + argsAlreadyRewritten: true); // args are now detached substituted nodes + + if (chainedAssignments is null) + return null; + + foreach (var kvp in chainedAssignments) + accumulated[kvp.Key] = kvp.Value; + } + } + + if (syntax.Body is null) + return accumulated; + // Use ConstructorBodyConverter (identity rewriter + param substitutions) so that - // if/else, local variables and simple assignments in the base ctor are all handled. + // if/else, local variables and simple assignments in the delegated ctor are all handled. + // Pass the already-accumulated chained assignments as the initial visible context. + IReadOnlyDictionary? initialCtx = + accumulated.Count > 0 ? accumulated : null; var converter = new ConstructorBodyConverter(context, paramToArg); - return converter.TryConvertBody(syntax.Body.Statements, memberName); + var bodyAssignments = converter.TryConvertBody(syntax.Body.Statements, memberName, initialCtx); + + if (bodyAssignments is null) + return null; + + foreach (var kvp in bodyAssignments) + accumulated[kvp.Key] = kvp.Value; + + return accumulated; + } + + /// + /// Substitutes identifiers in using the + /// mapping. This is used to forward the outer caller's arguments through a chain of + /// base/this initializer calls. + /// + private static SeparatedSyntaxList SubstituteArguments( + SeparatedSyntaxList args, + Dictionary paramToArg) + { + if (paramToArg.Count == 0) + return args; + + var result = new List(); + foreach (var arg in args) + { + var substituted = ConstructorBodyConverter.ParameterSubstitutor.Substitute( + arg.Expression, paramToArg); + result.Add(arg.WithExpression(substituted)); + } + return SyntaxFactory.SeparatedList(result); } private static TypeConstraintSyntax MakeTypeConstraint(string constraint) => SyntaxFactory.TypeConstraint(SyntaxFactory.IdentifierName(constraint)); diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt new file mode 100644 index 0000000..6004f0a --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N'-' + COALESCE([p].[LastName], N'') AS [Name] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet8_0.verified.txt new file mode 100644 index 0000000..6004f0a --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N'-' + COALESCE([p].[LastName], N'') AS [Name] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet9_0.verified.txt new file mode 100644 index 0000000..6004f0a --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N'-' + COALESCE([p].[LastName], N'') AS [Name] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.verified.txt new file mode 100644 index 0000000..6004f0a --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N'-' + COALESCE([p].[LastName], N'') AS [Name] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet10_0.verified.txt new file mode 100644 index 0000000..8464fb5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], N'' AS [LastName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet8_0.verified.txt new file mode 100644 index 0000000..8464fb5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], N'' AS [LastName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet9_0.verified.txt new file mode 100644 index 0000000..8464fb5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], N'' AS [LastName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.verified.txt new file mode 100644 index 0000000..8464fb5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_SimpleDelegate.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], N'' AS [LastName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt new file mode 100644 index 0000000..cccd5bb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet8_0.verified.txt new file mode 100644 index 0000000..cccd5bb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet8_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet9_0.verified.txt new file mode 100644 index 0000000..cccd5bb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.verified.txt new file mode 100644 index 0000000..cccd5bb --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.verified.txt @@ -0,0 +1,2 @@ +SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet10_0.verified.txt new file mode 100644 index 0000000..c6a5c18 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT [p].[Score], N'Grade:' + CASE + WHEN [p].[Score] >= 90 THEN N'A' + ELSE N'B' +END AS [Grade] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet8_0.verified.txt new file mode 100644 index 0000000..c6a5c18 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet8_0.verified.txt @@ -0,0 +1,5 @@ +SELECT [p].[Score], N'Grade:' + CASE + WHEN [p].[Score] >= 90 THEN N'A' + ELSE N'B' +END AS [Grade] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet9_0.verified.txt new file mode 100644 index 0000000..c6a5c18 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.DotNet9_0.verified.txt @@ -0,0 +1,5 @@ +SELECT [p].[Score], N'Grade:' + CASE + WHEN [p].[Score] >= 90 THEN N'A' + ELSE N'B' +END AS [Grade] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.verified.txt new file mode 100644 index 0000000..c6a5c18 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithIfElseInDelegated.verified.txt @@ -0,0 +1,5 @@ +SELECT [p].[Score], N'Grade:' + CASE + WHEN [p].[Score] >= 90 THEN N'A' + ELSE N'B' +END AS [Grade] +FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs index 4d366ef..af6d9ea 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.cs @@ -270,6 +270,103 @@ public PersonWithConstSeparatorDto(string firstName, string lastName) } } + // ── this() overload – simple delegation ─────────────────────────────────── + + public class PersonThisSimpleDto + { + public string FirstName { get; set; } + public string LastName { get; set; } + + public PersonThisSimpleDto() { } + + public PersonThisSimpleDto(string firstName, string lastName) + { + FirstName = firstName; + LastName = lastName; + } + + /// Delegates to the 2-arg ctor using a split on the full name. + [Projectable] + public PersonThisSimpleDto(string fullName) : this(fullName, "") + { + } + } + + // ── this() overload – with additional body after delegation ─────────────── + + public class PersonThisBodyAfterDto + { + public string FirstName { get; set; } + public string LastName { get; set; } + public string FullName { get; set; } + + public PersonThisBodyAfterDto() { } + + public PersonThisBodyAfterDto(string firstName, string lastName) + { + FirstName = firstName; + LastName = lastName; + } + + [Projectable] + public PersonThisBodyAfterDto(string firstName, string lastName, bool upper) : this(firstName, lastName) + { + FullName = upper ? (FirstName + " " + LastName).ToUpper() : FirstName + " " + LastName; + } + } + + // ── this() overload – if/else logic in the delegated constructor ────────── + + public class PersonThisIfElseDto + { + public string Grade { get; set; } + public int Score { get; set; } + + public PersonThisIfElseDto() { } + + public PersonThisIfElseDto(int score) + { + Score = score; + if (score >= 90) + Grade = "A"; + else + Grade = "B"; + } + + [Projectable] + public PersonThisIfElseDto(int score, string prefix) : this(score) + { + Grade = prefix + Grade; + } + } + + // ── this() → base() chain ───────────────────────────────────────────────── + + public class PersonChainBase + { + public int Id { get; set; } + public PersonChainBase() { } + public PersonChainBase(int id) { Id = id; } + } + + public class PersonChainChild : PersonChainBase + { + public string Name { get; set; } + + public PersonChainChild() { } + + public PersonChainChild(int id, string name) : base(id) + { + Name = name; + } + + [Projectable] + public PersonChainChild(int id, string name, string suffix) : this(id, name) + { + Name = Name + suffix; + } + } + [Fact] public Task Select_ScalarFieldsToDto() { @@ -421,6 +518,50 @@ public Task Select_ConstructorReferencingStaticConstMember() return Verifier.Verify(query.ToQueryString()); } + + [Fact] + public Task Select_ThisOverload_SimpleDelegate() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonThisSimpleDto(p.FirstName)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_ThisOverload_WithBodyAfterDelegation() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonThisBodyAfterDto(p.FirstName, p.LastName, false)); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_ThisOverload_WithIfElseInDelegated() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonThisIfElseDto(p.Score, "Grade:")); + + return Verifier.Verify(query.ToQueryString()); + } + + [Fact] + public Task Select_ThisOverload_ChainedThisAndBase() + { + using var dbContext = new SampleDbContext(); + + var query = dbContext.Set() + .Select(p => new PersonChainChild(p.Id, p.FirstName, "-" + p.LastName)); + + return Verifier.Verify(query.ToQueryString()); + } } } diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ChainedThisAndBase.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ChainedThisAndBase.DotNet8_0.verified.txt new file mode 100644 index 0000000..26be6b2 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ChainedThisAndBase.DotNet8_0.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_int_P1_string_P2_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int id, string name, string suffix) => new global::Foo.Child() + { + Id = id, + Name = (name) + suffix + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ChainedThisAndBase.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ChainedThisAndBase.verified.txt new file mode 100644 index 0000000..26be6b2 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ChainedThisAndBase.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_Child__ctor_P0_int_P1_string_P2_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int id, string name, string suffix) => new global::Foo.Child() + { + Id = id, + Name = (name) + suffix + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt new file mode 100644 index 0000000..34cc6fd --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ReferencingPreviouslyAssignedProperty.DotNet8_0.verified.txt @@ -0,0 +1,21 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string firstName) => new global::Foo.PersonDto() + { + FirstName = firstName, + LastName = "Doe", + FullName = (firstName) + " " + ("Doe") + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ReferencingPreviouslyAssignedProperty.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ReferencingPreviouslyAssignedProperty.verified.txt new file mode 100644 index 0000000..34cc6fd --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_ReferencingPreviouslyAssignedProperty.verified.txt @@ -0,0 +1,21 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string firstName) => new global::Foo.PersonDto() + { + FirstName = firstName, + LastName = "Doe", + FullName = (firstName) + " " + ("Doe") + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_SimpleOverload.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_SimpleOverload.DotNet8_0.verified.txt new file mode 100644 index 0000000..a03695d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_SimpleOverload.DotNet8_0.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string fullName) => new global::Foo.PersonDto() + { + FirstName = fullName.Split(' ')[0], + LastName = fullName.Split(' ')[1] + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_SimpleOverload.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_SimpleOverload.verified.txt new file mode 100644 index 0000000..a03695d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_SimpleOverload.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string fullName) => new global::Foo.PersonDto() + { + FirstName = fullName.Split(' ')[0], + LastName = fullName.Split(' ')[1] + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithBodyAfter.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithBodyAfter.DotNet8_0.verified.txt new file mode 100644 index 0000000..84c7f49 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithBodyAfter.DotNet8_0.verified.txt @@ -0,0 +1,21 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string_P1_string_P2_bool + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string fn, string ln, bool upper) => new global::Foo.PersonDto() + { + FirstName = fn, + LastName = ln, + FullName = upper ? ((fn) + " " + (ln)).ToUpper() : (fn) + " " + (ln) + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithBodyAfter.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithBodyAfter.verified.txt new file mode 100644 index 0000000..84c7f49 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithBodyAfter.verified.txt @@ -0,0 +1,21 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string_P1_string_P2_bool + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string fn, string ln, bool upper) => new global::Foo.PersonDto() + { + FirstName = fn, + LastName = ln, + FullName = upper ? ((fn) + " " + (ln)).ToUpper() : (fn) + " " + (ln) + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithIfElseInDelegated.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithIfElseInDelegated.DotNet8_0.verified.txt new file mode 100644 index 0000000..e5e1f53 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithIfElseInDelegated.DotNet8_0.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_int_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int score, string prefix) => new global::Foo.PersonDto() + { + Score = score, + Label = prefix + (score >= 90 ? "A" : "B") + }; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithIfElseInDelegated.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithIfElseInDelegated.verified.txt new file mode 100644 index 0000000..e5e1f53 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_ThisInitializer_WithIfElseInDelegated.verified.txt @@ -0,0 +1,20 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_int_P1_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (int score, string prefix) => new global::Foo.PersonDto() + { + Score = score, + Label = prefix + (score >= 90 ? "A" : "B") + }; + } + } +} \ 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 128218c..7c81361 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs @@ -3796,6 +3796,179 @@ public Child(int a, int b) : base(a, b) { return Verifier.Verify(result.GeneratedTrees[0].ToString()); } + [Fact] + public Task ProjectableConstructor_ThisInitializer_SimpleOverload() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string FirstName { get; set; } + public string LastName { get; set; } + + public PersonDto() { } + + public PersonDto(string firstName, string lastName) { + FirstName = firstName; + LastName = lastName; + } + + [Projectable] + public PersonDto(string fullName) : this(fullName.Split(' ')[0], fullName.Split(' ')[1]) { + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_ThisInitializer_WithBodyAfter() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string FirstName { get; set; } + public string LastName { get; set; } + public string FullName { get; set; } + + public PersonDto() { } + + public PersonDto(string firstName, string lastName) { + FirstName = firstName; + LastName = lastName; + } + + [Projectable] + public PersonDto(string fn, string ln, bool upper) : this(fn, ln) { + FullName = upper ? (FirstName + "" "" + LastName).ToUpper() : FirstName + "" "" + LastName; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_ThisInitializer_WithIfElseInDelegated() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string Label { get; set; } + public int Score { get; set; } + + public PersonDto() { } + + public PersonDto(int score) { + Score = score; + if (score >= 90) { + Label = ""A""; + } else { + Label = ""B""; + } + } + + [Projectable] + public PersonDto(int score, string prefix) : this(score) { + Label = prefix + Label; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_ThisInitializer_ChainedThisAndBase() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class Base { + public int Id { get; set; } + public Base(int id) { Id = id; } + } + + class Child : Base { + public string Name { get; set; } + + public Child() : base(0) { } + + public Child(int id, string name) : base(id) { + Name = name; + } + + [Projectable] + public Child(int id, string name, string suffix) : this(id, name) { + Name = Name + suffix; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + + [Fact] + public Task ProjectableConstructor_ThisInitializer_ReferencingPreviouslyAssignedProperty() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string FirstName { get; set; } + public string LastName { get; set; } + public string FullName { get; set; } + + public PersonDto() { } + + public PersonDto(string firstName, string lastName) { + FirstName = firstName; + LastName = lastName; + FullName = FirstName + "" "" + LastName; + } + + [Projectable] + public PersonDto(string firstName) : this(firstName, ""Doe"") { + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + #region Helpers Compilation CreateCompilation(string source, bool expectedToCompile = true) From ccfc63d802489595ed435486089c8d684462676c Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Sun, 22 Feb 2026 18:17:47 +0100 Subject: [PATCH 8/8] Add diagnostic for missing parameterless constructor in projectable constryuctors --- .../AnalyzerReleases.Shipped.md | 1 + .../Diagnostics.cs | 8 ++ .../ProjectableInterpreter.cs | 18 +++- ...onstructor_Succeeds.DotNet8_0.verified.txt | 19 ++++ ...meterlessConstructor_Succeeds.verified.txt | 19 ++++ .../ProjectionExpressionGeneratorTests.cs | 96 +++++++++++++++++++ 6 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithExplicitParameterlessConstructor_Succeeds.DotNet8_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithExplicitParameterlessConstructor_Succeeds.verified.txt diff --git a/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md b/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md index 253db78..249da29 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md +++ b/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md @@ -8,6 +8,7 @@ EFP0003 | Design | Warning | Unsupported statement in block-bodied method EFP0004 | Design | Error | Statement with side effects in block-bodied method EFP0005 | Design | Warning | Potential side effect in block-bodied method EFP0006 | Design | Error | Method or property should expose a body definition (block or expression) +EFP0007 | Design | Error | Target class is missing a parameterless constructor ### Changed Rules diff --git a/src/EntityFrameworkCore.Projectables.Generator/Diagnostics.cs b/src/EntityFrameworkCore.Projectables.Generator/Diagnostics.cs index 70e2964..5e88014 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/Diagnostics.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/Diagnostics.cs @@ -52,5 +52,13 @@ public static class Diagnostics DiagnosticSeverity.Error, isEnabledByDefault: true); + public static readonly DiagnosticDescriptor MissingParameterlessConstructor = new DiagnosticDescriptor( + id: "EFP0007", + title: "Target class is missing a parameterless constructor", + messageFormat: "Class '{0}' must have a parameterless constructor to be used with a [Projectable] constructor. The generated projection uses 'new {0}() {{ ... }}' (object-initializer syntax), which requires a publicly accessible parameterless constructor.", + category: "Design", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + } } diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs index 9e1d922..bda392f 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectableInterpreter.cs @@ -530,6 +530,23 @@ x is IPropertySymbol xProperty && return null; } + // Verify the containing type has a parameterless (instance) constructor. + // The generated projection is: new T() { Prop = ... }, which requires one. + // INamedTypeSymbol.Constructors covers all partial declarations and also + // the implicit parameterless constructor that the compiler synthesizes when + // no constructors are explicitly defined. + var hasParameterlessConstructor = containingType.Constructors + .Any(c => !c.IsStatic && c.Parameters.IsEmpty); + + if (!hasParameterlessConstructor) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.MissingParameterlessConstructor, + constructorDeclarationSyntax.GetLocation(), + containingType.Name)); + return null; + } + var initExpressions = accumulatedAssignments .Select(kvp => (ExpressionSyntax)SyntaxFactory.AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, @@ -543,7 +560,6 @@ x is IPropertySymbol xProperty && // Use a parameterless constructor + object initializer so EF Core only // projects columns explicitly listed in the member-init bindings. - // Requirement: the DTO must have a parameterless constructor. descriptor.ExpressionBody = SyntaxFactory.ObjectCreationExpression( SyntaxFactory.Token(SyntaxKind.NewKeyword).WithTrailingTrivia(SyntaxFactory.Space), SyntaxFactory.ParseTypeName(fullTypeName), diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithExplicitParameterlessConstructor_Succeeds.DotNet8_0.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithExplicitParameterlessConstructor_Succeeds.DotNet8_0.verified.txt new file mode 100644 index 0000000..1d391d0 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithExplicitParameterlessConstructor_Succeeds.DotNet8_0.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string name) => new global::Foo.PersonDto() + { + Name = name + }; + } + } +} diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithExplicitParameterlessConstructor_Succeeds.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithExplicitParameterlessConstructor_Succeeds.verified.txt new file mode 100644 index 0000000..59417ac --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.ProjectableConstructor_WithExplicitParameterlessConstructor_Succeeds.verified.txt @@ -0,0 +1,19 @@ +// +#nullable disable +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_PersonDto__ctor_P0_string + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (string name) => new global::Foo.PersonDto() + { + Name = 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 7c81361..b8d3378 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/ProjectionExpressionGeneratorTests.cs @@ -3384,6 +3384,8 @@ class PointDto { public int X { get; set; } public int Y { get; set; } + public PointDto() { } + [Projectable] public PointDto(int x, int y) { X = x; @@ -3410,11 +3412,15 @@ namespace Foo { class Base { public int Id { get; set; } public Base(int id) { Id = id; } + + protected Base() { } } class Child : Base { public string Name { get; set; } + public Child() { } + [Projectable] public Child(int id, string name) : base(id) { Name = name; @@ -3441,6 +3447,8 @@ class PersonDto { public string FirstName { get; set; } public string LastName { get; set; } + public PersonDto() { } + [Projectable] public PersonDto(string firstName, string lastName) { FirstName = firstName; @@ -3479,6 +3487,8 @@ class PersonDto { public int Id { get; set; } public string Name { get; set; } + public PersonDto() { } + [Projectable] public PersonDto(SourceEntity source) { Id = source.Id; @@ -3510,6 +3520,8 @@ class PersonDto { public string FirstName { get; set; } public string LastName { get; set; } + public PersonDto() { } + [Projectable] public PersonDto(NamePart first, NamePart last) { FirstName = first.Value; @@ -3537,6 +3549,8 @@ class PersonDto { public string Label { get; set; } public int Score { get; set; } + public PersonDto() { } + [Projectable] public PersonDto(int score) { Score = score; @@ -3567,6 +3581,8 @@ namespace Foo { class PersonDto { public string FullName { get; set; } + public PersonDto() { } + [Projectable] public PersonDto(string first, string last) { var full = first + "" "" + last; @@ -3593,11 +3609,15 @@ namespace Foo { class Base { public string Code { get; set; } public Base(string code) { Code = code; } + + protected Base() { } } class Child : Base { public string Name { get; set; } + public Child() { } + [Projectable] public Child(string name, string rawCode) : base(rawCode.ToUpper()) { Name = name; @@ -3629,11 +3649,15 @@ public Base(int id) { Id = id; } } + + protected Base() { } } class Child : Base { public string Name { get; set; } + public Child() { } + [Projectable] public Child(int id, string name) : base(id) { Name = name; @@ -3659,6 +3683,8 @@ namespace Foo { class PersonDto { public string Label { get; set; } + public PersonDto() { } + [Projectable] public PersonDto(int score) { Label = ""none""; @@ -3689,6 +3715,8 @@ class PersonDto { public string LastName { get; set; } public string FullName { get; set; } + public PersonDto() { } + [Projectable] public PersonDto(string firstName, string lastName) { FirstName = firstName; @@ -3716,11 +3744,15 @@ namespace Foo { class Base { public string Code { get; set; } public Base(string code) { Code = code; } + + protected Base() { } } class Child : Base { public string Label { get; set; } + public Child() { } + [Projectable] public Child(string code) : base(code) { Label = ""["" + Code + ""]""; @@ -3747,6 +3779,8 @@ class PersonDto { internal const string Separator = "" - ""; public string FullName { get; set; } + public PersonDto() { } + [Projectable] public PersonDto(string first, string last) { FullName = first + Separator + last; @@ -3776,11 +3810,15 @@ public Base(int x, int y) { X = x; Y = x + y; // Y depends on X's assigned value (x) } + + protected Base() { } } class Child : Base { public int Sum { get; set; } + public Child() { } + [Projectable] public Child(int a, int b) : base(a, b) { Sum = X + Y; @@ -3969,6 +4007,64 @@ public PersonDto(string firstName) : this(firstName, ""Doe"") { return Verifier.Verify(result.GeneratedTrees[0].ToString()); } + [Fact] + public void ProjectableConstructor_WithoutParameterlessConstructor_EmitsDiagnostic() + { + // A class that only exposes a parameterized constructor (no parameterless one). + // The generator must emit EFP0007 and produce no code because the object-initializer + // pattern requires a parameterless constructor. + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string Name { get; set; } + + // No parameterless constructor – only the one marked [Projectable]. + [Projectable] + public PersonDto(string name) { + Name = name; + } + } +} +"); + var result = RunGenerator(compilation); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0007", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Empty(result.GeneratedTrees); + } + + [Fact] + public Task ProjectableConstructor_WithExplicitParameterlessConstructor_Succeeds() + { + // A class that explicitly defines a parameterless constructor alongside the + // [Projectable] one – the generator should succeed and produce code. + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; + +namespace Foo { + class PersonDto { + public string Name { get; set; } + + public PersonDto() { } + + [Projectable] + public PersonDto(string name) { + Name = name; + } + } +} +"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } + #region Helpers Compilation CreateCompilation(string source, bool expectedToCompile = true)