Skip to content

Commit b37266b

Browse files
author
fabien.menager
committed
Apply code review suggestions
1 parent 5c76c32 commit b37266b

16 files changed

Lines changed: 433 additions & 40 deletions

File tree

src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodTransformationHelper.cs

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ async static internal Task<Solution> ConvertToConstructorAndUpdateCallersAsync(
139139
else
140140
{
141141
// Method group: Class.Method → p1 => new ReturnType(p1)
142+
// Skip nameof(Class.Method): SymbolFinder returns these locations but
143+
// replacing them with a lambda would produce invalid C#.
144+
if (IsInsideNameOf(methodRefExpr))
145+
{
146+
continue;
147+
}
148+
142149
if (annotationByMethodGroup.ContainsKey(methodRefExpr))
143150
{
144151
continue;
@@ -305,6 +312,13 @@ async static internal Task<Solution> ConvertToConstructorAndUpdateCallersAsync(
305312
else
306313
{
307314
// Method group: Class.Method → p => new ReturnType(p)
315+
// Skip nameof(Class.Method): SymbolFinder returns these locations but
316+
// replacing them with a lambda would produce invalid C#.
317+
if (IsInsideNameOf(methodRefExpr))
318+
{
319+
continue;
320+
}
321+
308322
var lambda = BuildMethodGroupLambda(methodParamNames, returnTypeSyntax)
309323
.WithLeadingTrivia(methodRefExpr.GetLeadingTrivia())
310324
.WithTrailingTrivia(methodRefExpr.GetTrailingTrivia());
@@ -329,7 +343,7 @@ private static SyntaxNode BuildRootWithConstructor(
329343
MethodDeclarationSyntax method,
330344
TypeDeclarationSyntax containingType)
331345
{
332-
var creation = (ObjectCreationExpressionSyntax)method.ExpressionBody!.Expression;
346+
var creation = (BaseObjectCreationExpressionSyntax)method.ExpressionBody!.Expression;
333347
var initializer = creation.Initializer!;
334348

335349
// Only support simple object-initializer assignments (Prop = value). If there are
@@ -341,10 +355,32 @@ private static SyntaxNode BuildRootWithConstructor(
341355
}
342356

343357
// Convert each object-initializer assignment (Prop = value) to a statement (Prop = value;).
344-
var statements = initializer.Expressions
345-
.OfType<AssignmentExpressionSyntax>()
346-
.Select(a => (StatementSyntax)SyntaxFactory.ExpressionStatement(a))
347-
.ToArray();
358+
// Two trivia sources must be preserved:
359+
// 1. The expression's own trailing trivia (e.g. an inline "// comment") must move onto
360+
// the semicolon token so it stays on the same line as the statement terminator.
361+
// 2. The separator comma's trailing trivia (e.g. "\r\n ") carries the newline and
362+
// indentation that separates adjacent items in the initializer list. That trivia
363+
// is lost when we extract only the expressions, so we append it to the semicolon as
364+
// well so the next statement starts on its own correctly-indented line.
365+
static StatementSyntax ToStatement(AssignmentExpressionSyntax a, SyntaxTriviaList separatorTrailing)
366+
{
367+
var exprTrailing = a.GetTrailingTrivia();
368+
var semicolonTrailing = exprTrailing.AddRange(separatorTrailing);
369+
return SyntaxFactory.ExpressionStatement(
370+
a.WithoutTrailingTrivia(),
371+
SyntaxFactory.Token(SyntaxKind.SemicolonToken).WithTrailingTrivia(semicolonTrailing));
372+
}
373+
374+
var exprs = initializer.Expressions;
375+
var statements = new StatementSyntax[exprs.Count];
376+
for (var i = 0; i < exprs.Count; i++)
377+
{
378+
var a = (AssignmentExpressionSyntax)exprs[i];
379+
var sepTrivia = i < exprs.SeparatorCount
380+
? exprs.GetSeparator(i).TrailingTrivia
381+
: default;
382+
statements[i] = ToStatement(a, sepTrivia);
383+
}
348384

349385
var ctorModifiers = GetConstructorModifiers(method);
350386

@@ -361,17 +397,24 @@ private static SyntaxNode BuildRootWithConstructor(
361397
var newMembers = containingType.Members.RemoveAt(methodIndex);
362398
newMembers = newMembers.Insert(Math.Min(methodIndex, newMembers.Count), ctor);
363399

364-
// Add an explicit parameterless constructor if the class has none, to avoid breaking
365-
// code that relied on the implicit default constructor.
366-
var hasParamlessCtor = containingType.Members
367-
.OfType<ConstructorDeclarationSyntax>()
368-
.Any(c => c.ParameterList.Parameters.Count == 0);
400+
// Add an explicit parameterless constructor only when the class originally had NO
401+
// explicit constructors at all. The C# compiler emits an implicit parameterless
402+
// constructor solely in that case (C# spec §10.11.4), and it is always declared public.
403+
// If the class already had other user-declared constructors the implicit default was
404+
// already suppressed, so we must not introduce a new public overload.
405+
var existingCtors = containingType.Members.OfType<ConstructorDeclarationSyntax>().ToArray();
406+
var hasParamlessCtor = existingCtors.Any(c => c.ParameterList.Parameters.Count == 0);
407+
var hadNoExplicitCtors = existingCtors.Length == 0;
369408

370-
if (!hasParamlessCtor)
409+
if (!hasParamlessCtor && hadNoExplicitCtors)
371410
{
411+
// The implicit default ctor is always public (C# spec §10.11.4) regardless of the
412+
// factory method's own accessibility, so hard-code public here.
372413
var paramlessCtor = SyntaxFactory
373414
.ConstructorDeclaration(containingType.Identifier.WithoutTrivia())
374-
.WithModifiers(ctorModifiers)
415+
.WithModifiers(SyntaxFactory.TokenList(
416+
SyntaxFactory.Token(SyntaxKind.PublicKeyword)
417+
.WithTrailingTrivia(SyntaxFactory.Space)))
375418
.WithParameterList(SyntaxFactory.ParameterList())
376419
.WithBody(SyntaxFactory.Block())
377420
.WithAdditionalAnnotations(Formatter.Annotation)
@@ -397,6 +440,29 @@ private static SyntaxTokenList GetConstructorModifiers(MethodDeclarationSyntax m
397440
return SyntaxFactory.TokenList(filteredModifiers);
398441
}
399442

443+
/// <summary>
444+
/// Returns <see langword="true"/> when <paramref name="expr"/> is the argument of a
445+
/// <c>nameof(…)</c> expression.
446+
/// <para>
447+
/// Roslyn parses <c>nameof(X.Y)</c> as an <see cref="InvocationExpressionSyntax"/> whose
448+
/// callee is an <see cref="IdentifierNameSyntax"/> with the text <c>nameof</c>.
449+
/// <see cref="Microsoft.CodeAnalysis.FindSymbols.SymbolFinder"/> still returns such
450+
/// locations, but replacing them with a lambda or object-creation expression would produce
451+
/// invalid C# — they must be skipped.
452+
/// </para>
453+
/// </summary>
454+
private static bool IsInsideNameOf(ExpressionSyntax expr) =>
455+
expr.Parent is ArgumentSyntax
456+
{
457+
Parent: ArgumentListSyntax
458+
{
459+
Parent: InvocationExpressionSyntax
460+
{
461+
Expression: IdentifierNameSyntax { Identifier.Text: "nameof" }
462+
}
463+
}
464+
};
465+
400466
/// <summary>
401467
/// Builds a lambda expression that wraps a constructor call, for use when a factory
402468
/// method is referenced as a method group (e.g. <c>Select(MyType.Create)</c>).

src/EntityFrameworkCore.Projectables.CodeFixes/SyntaxHelpers.cs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using Microsoft.CodeAnalysis.CSharp.Syntax;
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using Microsoft.CodeAnalysis.CSharp.Syntax;
24

35
namespace EntityFrameworkCore.Projectables.CodeFixes;
46

@@ -8,11 +10,16 @@ static internal class SyntaxHelpers
810
/// Returns <see langword="true"/> when <paramref name="method"/> matches the
911
/// factory-method pattern:
1012
/// <list type="bullet">
11-
/// <item><description>Decorated with <c>[Projectable]</c>.</description></item>
1213
/// <item><description>Expression body of the form <c>=> new ContainingType { … }</c>
1314
/// (object initializer only, no constructor arguments in the <c>new</c>
1415
/// expression).</description></item>
16+
/// <item><description>Static method.</description></item>
1517
/// <item><description>Return type simple name equals the containing class name.</description></item>
18+
/// <item><description>For explicit <c>new T { … }</c>, <c>T</c> must be an unqualified
19+
/// identifier that matches the containing class name. Qualified names such as
20+
/// <c>new Other.MyObj { … }</c> or <c>new global::Other.MyObj { … }</c> are
21+
/// rejected because they cannot be confirmed as the same type without a semantic
22+
/// model.</description></item>
1623
/// </list>
1724
/// </summary>
1825
static internal bool TryGetFactoryMethodPattern(
@@ -47,6 +54,33 @@ static internal bool TryGetFactoryMethodPattern(
4754
return false;
4855
}
4956

57+
// Only allow static factory methods, to keep the code fix simpler
58+
if (!method.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword)))
59+
{
60+
return false;
61+
}
62+
63+
// For explicit new T { … }, verify the type is an unqualified name matching the
64+
// containing class. Qualified names (new Other.MyObj { }, new global::Other.MyObj { })
65+
// are rejected: without a semantic model we cannot confirm they resolve to the same
66+
// type, so this is the conservative safe choice.
67+
// For implicit new() { }, the compiler infers the type from the method's return type,
68+
// which is already validated below, so no additional check is needed here.
69+
if (creation is ObjectCreationExpressionSyntax { Type: var createdType })
70+
{
71+
var createdTypeName = createdType switch
72+
{
73+
IdentifierNameSyntax id => id.Identifier.Text,
74+
GenericNameSyntax generic => generic.Identifier.Text,
75+
_ => null // QualifiedNameSyntax, AliasQualifiedNameSyntax, etc. — reject
76+
};
77+
78+
if (createdTypeName is null || createdTypeName != parentType.Identifier.Text)
79+
{
80+
return false;
81+
}
82+
}
83+
5084
// The method's return type must match the containing type (syntax-level name comparison).
5185
var returnTypeName = method.ReturnType switch
5286
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+

2+
namespace Foo {
3+
class Input { public int Value { get; set; } }
4+
class Output {
5+
public int Value { get; set; }
6+
public Output(string name) { }
7+
[Projectable]
8+
public Output(Input i)
9+
{
10+
Value = i.Value;
11+
}
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+

2+
namespace Foo {
3+
class OtherObj { public string Prop1 { get; set; } }
4+
class MyObj {
5+
public MyObj()
6+
{
7+
}
8+
9+
public string Prop1 { get; set; }
10+
[Projectable]
11+
public MyObj(OtherObj obj)
12+
{
13+
Prop1 = obj.Prop1;
14+
}
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+

2+
namespace Foo {
3+
class Input { }
4+
class Output {
5+
public Output()
6+
{
7+
}
8+
9+
public int Value { get; set; }
10+
[Projectable]
11+
internal Output(Input i)
12+
{
13+
}
14+
}
15+
}

tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,77 @@ public Output() { }
9696
_provider,
9797
actionIndex: 0));
9898

99+
/// <summary>
100+
/// When the class already has at least one explicit constructor (but no parameterless one),
101+
/// the C# compiler did NOT generate an implicit default constructor — so the transformation
102+
/// must NOT insert one, which would unintentionally widen the public surface area.
103+
/// </summary>
104+
[Fact]
105+
public Task CodeFix_DoesNotAddParameterlessConstructor_WhenOtherExplicitCtorExists() =>
106+
Verifier.Verify(
107+
ApplyCodeFixAsync(
108+
@"
109+
namespace Foo {
110+
class Input { public int Value { get; set; } }
111+
class Output {
112+
public int Value { get; set; }
113+
public Output(string name) { }
114+
[Projectable]
115+
public static Output Create(Input i) => new Output { Value = i.Value };
116+
}
117+
}",
118+
"EFP0012",
119+
FirstMethodIdentifierSpan,
120+
_provider,
121+
actionIndex: 0));
122+
123+
/// <summary>
124+
/// The implicit default constructor is always public (C# spec §10.11.4) regardless of the
125+
/// factory method's accessibility. The inserted explicit parameterless ctor must therefore
126+
/// be public too, even when the factory method is internal or protected.
127+
/// </summary>
128+
[Fact]
129+
public Task CodeFix_InsertedParameterlessCtorIsAlwaysPublic() =>
130+
Verifier.Verify(
131+
ApplyCodeFixAsync(
132+
@"
133+
namespace Foo {
134+
class Input { }
135+
class Output {
136+
public int Value { get; set; }
137+
[Projectable]
138+
internal static Output Create(Input i) => new Output { };
139+
}
140+
}",
141+
"EFP0012",
142+
FirstMethodIdentifierSpan,
143+
_provider,
144+
actionIndex: 0));
145+
146+
/// <summary>
147+
/// Regression test: implicit object creation (<c>new() { … }</c>) must not throw
148+
/// an <see cref="InvalidCastException"/> — <see cref="FactoryMethodTransformationHelper"/>
149+
/// must treat it as <see cref="Microsoft.CodeAnalysis.CSharp.Syntax.BaseObjectCreationExpressionSyntax"/>
150+
/// rather than casting to the explicit <c>ObjectCreationExpressionSyntax</c>.
151+
/// </summary>
152+
[Fact]
153+
public Task CodeFix_ImplicitObjectCreation() =>
154+
Verifier.Verify(
155+
ApplyCodeFixAsync(
156+
@"
157+
namespace Foo {
158+
class OtherObj { public string Prop1 { get; set; } }
159+
class MyObj {
160+
public string Prop1 { get; set; }
161+
[Projectable]
162+
public static MyObj Create(OtherObj obj) => new() { Prop1 = obj.Prop1 };
163+
}
164+
}",
165+
"EFP0012",
166+
FirstMethodIdentifierSpan,
167+
_provider,
168+
actionIndex: 0));
169+
99170
[Fact]
100171
public async Task TwoCodeFixActionsAreOffered()
101172
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+

2+
namespace Foo {
3+
class Input { public int Value { get; set; } }
4+
class Output {
5+
public int Value { get; set; }
6+
public Output(string name) { }
7+
[Projectable]
8+
public Output(Input i)
9+
{
10+
Value = i.Value;
11+
}
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+

2+
namespace Foo {
3+
class OtherObj { public string Prop1 { get; set; } }
4+
class MyObj {
5+
public MyObj()
6+
{
7+
}
8+
9+
public string Prop1 { get; set; }
10+
[Projectable]
11+
public MyObj(OtherObj obj)
12+
{
13+
Prop1 = obj.Prop1;
14+
}
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+

2+
namespace Foo {
3+
class Input { }
4+
class Output {
5+
public Output()
6+
{
7+
}
8+
9+
public int Value { get; set; }
10+
[Projectable]
11+
internal Output(Input i)
12+
{
13+
}
14+
}
15+
}

tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_MultipleInitializerAssignments.verified.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ namespace Foo {
1111
[Projectable]
1212
public Dest(Src src)
1313
{
14-
A = src.A;
15-
B = src.B;
14+
A = src.A; B = src.B;
1615
}
1716
}
1817
}

0 commit comments

Comments
 (0)