Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ef840f6
Initial plan
Copilot Feb 14, 2026
8468dc5
Add support for block-bodied methods with common statements
Copilot Feb 14, 2026
f68c572
Add functional tests and documentation for block-bodied methods
Copilot Feb 14, 2026
7067bc2
Add support for if-without-else and switch statements
Copilot Feb 14, 2026
75c638e
Address code review feedback
Copilot Feb 14, 2026
7a70fde
Merge branch 'master' into copilot/support-classic-methods-transforma…
Feb 15, 2026
06627c2
Remove unused code and add support for multiple early returns
Feb 15, 2026
9add2c9
Missing verify files
Feb 15, 2026
3c91bf9
Update docs
Feb 15, 2026
e2b1fad
Address code review feedback - fix semantics and scoping issues
Copilot Feb 15, 2026
f7f296b
Improve switch expression support and improve variable handling
Feb 15, 2026
3b56faa
Remove redundant code and add test for projectables in block bodied m…
Feb 15, 2026
ff4feb1
Fix new case
Feb 15, 2026
7ba8a84
Fix local variable replacement in conditions and switch expressions
Copilot Feb 16, 2026
b8af892
Improve error reporting for side effects in block-bodied methods
Copilot Feb 16, 2026
b697cd2
Add documentation for side effect detection
Copilot Feb 16, 2026
f2a805e
Initial exploration - understand pattern matching crash issue
Copilot Feb 16, 2026
31f4267
Fix pattern matching support in block-bodied methods
Copilot Feb 16, 2026
adc95f5
Add documentation for pattern matching support
Copilot Feb 16, 2026
5f83f40
Revert pattern matching commits to separate feature
Copilot Feb 16, 2026
8b144b5
Address code review suggestions and update documentation
Copilot Feb 16, 2026
2b70d43
Update block bodied release number
Feb 16, 2026
b4989af
Reveret change about if with single return
Feb 16, 2026
a741a27
Mark this new feature as experimental and allow explicit getters for …
Feb 18, 2026
3788729
Fix block bodied properties
Feb 18, 2026
f2d0ec8
Simplify code and add xmldocs
Feb 18, 2026
9b7e61a
Handle code review suggestions and fix an operator precedence issue w…
Feb 18, 2026
3f6cb13
Add support for projectable constructors
Feb 21, 2026
9e222e0
Require parameterless constructor to improve optimized mapping by EF …
Feb 21, 2026
c5b1ab6
Improve base constructor mapping
Feb 21, 2026
403924e
Improve base constructor mapping
Feb 21, 2026
41c653b
Supoort logic in constructors
Feb 22, 2026
d84a121
Improve ctor body generation
Feb 22, 2026
2b7159f
Enhance delegated constructor handling with recursive base/this initi…
Feb 22, 2026
ccfc63d
Add diagnostic for missing parameterless constructor in projectable c…
Feb 22, 2026
2f88136
Merge branch 'master' into feature/projectable-constructor
Mar 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
namespace EntityFrameworkCore.Projectables
{
/// <summary>
/// Declares this property or method to be Projectable.
/// Declares this property, method or constructor to be Projectable.
/// A companion Expression tree will be generated
/// </summary>
[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
{
/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/EntityFrameworkCore.Projectables.Generator/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<UsingDirectiveSyntax>(),
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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -452,6 +457,116 @@ 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);
}

// Accumulated property-name → expression map (later converted to member-init)
var accumulatedAssignments = new Dictionary<string, ExpressionSyntax>();

// 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 delegatedAssignments = CollectDelegatedConstructorAssignments(
initializerSymbol,
initializer.ArgumentList.Arguments,
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 (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);
IReadOnlyDictionary<string, ExpressionSyntax>? initialCtx =
accumulatedAssignments.Count > 0 ? accumulatedAssignments : null;
var bodyAssignments = bodyConverter.TryConvertBody(body.Statements, memberSymbol.Name, initialCtx);

if (bodyAssignments is null)
{
return null;
}

// Body assignments override anything set by the base/this initializer
foreach (var kvp in bodyAssignments)
{
accumulatedAssignments[kvp.Key] = kvp.Value;
}
}

if (accumulatedAssignments.Count == 0)
{
var diag = Diagnostic.Create(Diagnostics.RequiresBodyDefinition,
constructorDeclarationSyntax.GetLocation(), memberSymbol.Name);
context.ReportDiagnostic(diag);
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;
Comment on lines +533 to +547
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

The parameterless-constructor check only verifies existence (any non-static ctor with zero parameters) but doesn’t verify accessibility from the generated projection class. If the only parameterless ctor is private/protected/private-protected, the generator will still emit new T() { ... }, which will fail compilation. Consider checking DeclaredAccessibility (e.g., public/internal/protected internal) and aligning the EFP0007 diagnostic text accordingly.

Copilot uses AI. Check for mistakes.
}

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));

// Use a parameterless constructor + object initializer so EF Core only
// projects columns explicitly listed in the member-init bindings.
descriptor.ExpressionBody = SyntaxFactory.ObjectCreationExpression(
SyntaxFactory.Token(SyntaxKind.NewKeyword).WithTrailingTrivia(SyntaxFactory.Space),
SyntaxFactory.ParseTypeName(fullTypeName),
SyntaxFactory.ArgumentList(),
memberInit
);
}
else
{
return null;
Expand All @@ -460,6 +575,123 @@ x is IPropertySymbol xProperty &&
return descriptor;
}

/// <summary>
/// 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, and follows the chain of
/// base/this initializers recursively.
/// Returns <c>null</c> when an unsupported statement is encountered (diagnostics reported).
/// </summary>
private static Dictionary<string, ExpressionSyntax>? CollectDelegatedConstructorAssignments(
IMethodSymbol delegatedCtor,
SeparatedSyntaxList<ArgumentSyntax> callerArgs,
ExpressionSyntaxRewriter expressionSyntaxRewriter,
SourceProductionContext context,
string memberName,
bool argsAlreadyRewritten = false)
{
// Only process constructors whose source is available in this compilation
var syntax = delegatedCtor.DeclaringSyntaxReferences
.Select(r => r.GetSyntax())
.OfType<ConstructorDeclarationSyntax>()
.FirstOrDefault();

if (syntax is null)
{
return new Dictionary<string, ExpressionSyntax>();
}

// 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<string, ExpressionSyntax>();
for (var i = 0; i < callerArgs.Count && i < delegatedCtor.Parameters.Length; i++)
{
var paramName = delegatedCtor.Parameters[i].Name;
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<string, ExpressionSyntax>();

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();
Comment on lines +624 to +626
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

CollectDelegatedConstructorAssignments resolves delegated initializer symbols using expressionSyntaxRewriter.GetSemanticModel(), but that SemanticModel is bound to the original member’s SyntaxTree. If the delegated/base constructor is declared in a different file (common with partials or base types), SemanticModel.GetSymbolInfo(delegatedInitializer) will throw because the node isn’t from that tree. Consider obtaining the semantic model for syntax.SyntaxTree (e.g., via Compilation.GetSemanticModel(syntax.SyntaxTree)) and using that instead, or pass the Compilation/semantic-model provider into this helper.

Suggested change
// 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();
// Obtain a SemanticModel for the delegated constructor's syntax tree so that
// the initializer node belongs to the same tree as the semantic model.
var semanticModel = context.Compilation.GetSemanticModel(syntax.SyntaxTree);

Copilot uses AI. Check for mistakes.
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(<caller's expression for id>)`.
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 delegated ctor are all handled.
// Pass the already-accumulated chained assignments as the initial visible context.
IReadOnlyDictionary<string, ExpressionSyntax>? initialCtx =
accumulated.Count > 0 ? accumulated : null;
var converter = new ConstructorBodyConverter(context, paramToArg);
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;
}

/// <summary>
/// Substitutes identifiers in <paramref name="args"/> using the <paramref name="paramToArg"/>
/// mapping. This is used to forward the outer caller's arguments through a chain of
/// base/this initializer calls.
/// </summary>
private static SeparatedSyntaxList<ArgumentSyntax> SubstituteArguments(
SeparatedSyntaxList<ArgumentSyntax> args,
Dictionary<string, ExpressionSyntax> paramToArg)
{
if (paramToArg.Count == 0)
return args;

var result = new List<ArgumentSyntax>();
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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public sealed class ProjectableExpressionReplacer : ExpressionVisitor
private readonly IProjectionExpressionResolver _resolver;
private readonly ExpressionArgumentReplacer _expressionArgumentReplacer = new();
private readonly Dictionary<MemberInfo, LambdaExpression?> _projectableMemberCache = new();
private readonly HashSet<ConstructorInfo> _expandingConstructors = new();
private IQueryProvider? _currentQueryProvider;
private bool _disableRootRewrite = false;
private readonly bool _trackingByDefault;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);

Expand Down
Loading
Loading