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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,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;
}

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