diff --git a/samples/ReadmeSample/ReadmeSample.csproj b/samples/ReadmeSample/ReadmeSample.csproj
index b1dc659..72664f6 100644
--- a/samples/ReadmeSample/ReadmeSample.csproj
+++ b/samples/ReadmeSample/ReadmeSample.csproj
@@ -13,6 +13,7 @@
+
diff --git a/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToConstructorCodeRefactoringProvider.cs b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToConstructorCodeRefactoringProvider.cs
new file mode 100644
index 0000000..4a8fb20
--- /dev/null
+++ b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToConstructorCodeRefactoringProvider.cs
@@ -0,0 +1,66 @@
+using System.Composition;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeRefactorings;
+
+namespace EntityFrameworkCore.Projectables.CodeFixes;
+
+///
+/// Code refactoring provider that converts a [Projectable] factory method whose body is
+/// an object-initializer expression (=> new T { … }) into a [Projectable]
+/// constructor of the same class.
+///
+/// Two refactoring actions are offered:
+///
+/// - Convert the factory method to a constructor (current document only).
+/// - Convert the factory method to a constructor and replace all
+/// callers throughout the solution with new T(…) invocations.
+///
+///
+///
+/// This provider is complementary to ,
+/// which fixes the EFP0012 diagnostic. The refactoring provider remains useful when
+/// the diagnostic is suppressed.
+///
+///
+/// A public parameterless constructor is automatically inserted when the class does not already
+/// have one, preserving the implicit default constructor that would otherwise be lost.
+///
+///
+[ExportCodeRefactoringProvider(LanguageNames.CSharp, Name = nameof(FactoryMethodToConstructorCodeRefactoringProvider))]
+[Shared]
+public sealed class FactoryMethodToConstructorCodeRefactoringProvider : CodeRefactoringProvider
+{
+ ///
+ public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return;
+ }
+
+ var node = root.FindNode(context.Span);
+
+ if (!ProjectableCodeFixHelper.TryGetFixableFactoryMethodPattern(node, out var containingType, out var method))
+ {
+ return;
+ }
+
+ context.RegisterRefactoring(
+ CodeAction.Create(
+ title: "Convert [Projectable] factory method to constructor",
+ createChangedDocument: ct =>
+ FactoryMethodTransformationHelper.ConvertToConstructorAsync(
+ context.Document, method!, containingType!, ct),
+ equivalenceKey: "EFP_FactoryToConstructor"));
+
+ context.RegisterRefactoring(
+ CodeAction.Create(
+ title: "Convert [Projectable] factory method to constructor (and update callers)",
+ createChangedSolution: ct =>
+ FactoryMethodTransformationHelper.ConvertToConstructorAndUpdateCallersAsync(
+ context.Document, method!, containingType!, ct),
+ equivalenceKey: "EFP_FactoryToConstructorWithCallers"));
+ }
+}
diff --git a/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToCtorCodeFixProvider.cs b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToCtorCodeFixProvider.cs
new file mode 100644
index 0000000..8136254
--- /dev/null
+++ b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToCtorCodeFixProvider.cs
@@ -0,0 +1,63 @@
+using System.Collections.Immutable;
+using System.Composition;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+
+namespace EntityFrameworkCore.Projectables.CodeFixes;
+
+///
+/// Code fix provider for EFP0012.
+/// Offers two fixes on a [Projectable] factory method that can be a constructor:
+///
+/// - Convert the factory method to a constructor (current document).
+/// - Convert the factory method to a constructor and update all
+/// callers throughout the solution.
+///
+///
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(FactoryMethodToCtorCodeFixProvider))]
+[Shared]
+public sealed class FactoryMethodToCtorCodeFixProvider : CodeFixProvider
+{
+ ///
+ public override ImmutableArray FixableDiagnosticIds => ["EFP0012"];
+
+ ///
+ public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+ ///
+ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return;
+ }
+
+ var node = root.FindNode(context.Span);
+
+ if (!ProjectableCodeFixHelper.TryGetFixableFactoryMethodPattern(node, out var containingType, out var method))
+ {
+ return;
+ }
+
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: "Convert [Projectable] factory method to constructor",
+ createChangedDocument: ct =>
+ FactoryMethodTransformationHelper.ConvertToConstructorAsync(
+ context.Document, method!, containingType!, ct),
+ equivalenceKey: "EFP0012_FactoryToConstructor"),
+ context.Diagnostics[0]);
+
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: "Convert [Projectable] factory method to constructor (and update callers)",
+ createChangedSolution: ct =>
+ FactoryMethodTransformationHelper.ConvertToConstructorAndUpdateCallersAsync(
+ context.Document, method!, containingType!, ct),
+ equivalenceKey: "EFP0012_FactoryToConstructorWithCallers"),
+ context.Diagnostics[0]);
+ }
+}
+
diff --git a/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodTransformationHelper.cs b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodTransformationHelper.cs
new file mode 100644
index 0000000..f8f560f
--- /dev/null
+++ b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodTransformationHelper.cs
@@ -0,0 +1,508 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.FindSymbols;
+using Microsoft.CodeAnalysis.Formatting;
+using Microsoft.CodeAnalysis.Simplification;
+
+namespace EntityFrameworkCore.Projectables.CodeFixes;
+
+///
+/// Shared helpers for the factory-method → projectable-constructor transformation.
+/// Used by both and
+/// .
+///
+static internal class FactoryMethodTransformationHelper
+{
+ ///
+ /// Fetches a fresh root, applies the factory → constructor transformation, and
+ /// returns an updated document.
+ ///
+ async static internal Task ConvertToConstructorAsync(
+ Document document,
+ MethodDeclarationSyntax method,
+ TypeDeclarationSyntax containingType,
+ CancellationToken cancellationToken)
+ {
+ var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return document;
+ }
+
+ return document.WithSyntaxRoot(BuildRootWithConstructor(root, method, containingType));
+ }
+
+ ///
+ /// Applies the factory → constructor transformation on the declaring document and
+ /// replaces all instance.FactoryMethod(args) call sites in the solution with
+ /// new ReturnType(args).
+ ///
+ async static internal Task ConvertToConstructorAndUpdateCallersAsync(
+ Document document,
+ MethodDeclarationSyntax method,
+ TypeDeclarationSyntax containingType,
+ CancellationToken cancellationToken)
+ {
+ var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
+ if (semanticModel is null)
+ {
+ return document.Project.Solution;
+ }
+
+ var methodSymbol = semanticModel.GetDeclaredSymbol(method, cancellationToken);
+ if (methodSymbol is null)
+ {
+ return document.Project.Solution;
+ }
+
+ var solution = document.Project.Solution;
+ var methodParamNames = methodSymbol.Parameters.Select(p => p.Name).ToArray();
+ var returnType = methodSymbol.ReturnType;
+ var returnTypeSyntax = SyntaxFactory
+ .ParseTypeName(returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))
+ .WithAdditionalAnnotations(Simplifier.Annotation);
+
+ // Find all callers BEFORE modifying the solution so that spans are still valid.
+ var references = await SymbolFinder
+ .FindReferencesAsync(methodSymbol, solution, cancellationToken)
+ .ConfigureAwait(false);
+
+ // Group locations by document (including the declaring document).
+ var locationsByDoc = new Dictionary>();
+ foreach (var referencedSymbol in references)
+ {
+ foreach (var refLocation in referencedSymbol.Locations)
+ {
+ if (!locationsByDoc.TryGetValue(refLocation.Document.Id, out var list))
+ {
+ list = [];
+ locationsByDoc[refLocation.Document.Id] = list;
+ }
+
+ list.Add(refLocation);
+ }
+ }
+
+ var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return solution;
+ }
+
+ // Map annotation → data needed to build the replacement node.
+ var invocationAnnotations = new Dictionary();
+ var methodGroupAnnotations = new Dictionary();
+
+ var workingRoot = root;
+
+ if (locationsByDoc.TryGetValue(document.Id, out var declaringDocLocations))
+ {
+ // Use Dictionaries keyed by node to deduplicate: multiple reference spans
+ // can resolve to the same syntax node.
+ var annotationByInvocation = new Dictionary();
+ var annotationByMethodGroup = new Dictionary();
+
+ foreach (var refLocation in declaringDocLocations)
+ {
+ var refNode = root.FindNode(refLocation.Location.SourceSpan);
+
+ // Determine the method-reference expression: qualified (Class.Method) or simple (Method).
+ var methodRefExpr = refNode.Parent is MemberAccessExpressionSyntax maExpr && maExpr.Name == refNode
+ ? maExpr
+ : (ExpressionSyntax)refNode;
+
+ if (methodRefExpr.Parent is InvocationExpressionSyntax invocation
+ && invocation.Expression == methodRefExpr)
+ {
+ // Direct invocation: Class.Method(args) → new ReturnType(args)
+ if (invocation.Parent is ConditionalAccessExpressionSyntax)
+ {
+ continue;
+ }
+
+ if (annotationByInvocation.ContainsKey(invocation))
+ {
+ // Same invocation reached via a different reference span — skip.
+ continue;
+ }
+
+ var ann = new SyntaxAnnotation();
+ invocationAnnotations[ann] = (
+ invocation.ArgumentList,
+ invocation.GetLeadingTrivia(),
+ invocation.GetTrailingTrivia());
+ annotationByInvocation[invocation] = ann;
+ }
+ else
+ {
+ // Method group: Class.Method → p1 => new ReturnType(p1)
+ // Skip nameof(Class.Method): SymbolFinder returns these locations but
+ // replacing them with a lambda would produce invalid C#.
+ if (IsInsideNameOf(methodRefExpr))
+ {
+ continue;
+ }
+
+ if (annotationByMethodGroup.ContainsKey(methodRefExpr))
+ {
+ continue;
+ }
+
+ var ann = new SyntaxAnnotation();
+ methodGroupAnnotations[ann] = (
+ methodRefExpr.GetLeadingTrivia(),
+ methodRefExpr.GetTrailingTrivia());
+ annotationByMethodGroup[methodRefExpr] = ann;
+ }
+ }
+
+ // Merge all nodes-to-annotate into one ReplaceNodes pass (does NOT shift spans).
+ var nodesToAnnotate = new Dictionary(
+ annotationByInvocation.Count + annotationByMethodGroup.Count);
+ foreach (var kvp in annotationByInvocation)
+ {
+ nodesToAnnotate[kvp.Key] = kvp.Value;
+ }
+
+ foreach (var kvp in annotationByMethodGroup)
+ {
+ nodesToAnnotate[kvp.Key] = kvp.Value;
+ }
+
+ if (nodesToAnnotate.Count > 0)
+ {
+ workingRoot = root.ReplaceNodes(
+ nodesToAnnotate.Keys,
+ (original, _) => original.WithAdditionalAnnotations(nodesToAnnotate[original]));
+ }
+ }
+
+ // Re-find method and containingType in workingRoot by their original spans
+ // (safe because adding annotations does not shift spans).
+ var currentMethod = workingRoot.FindNode(method.Span) as MethodDeclarationSyntax ?? method;
+ var currentContainingType = workingRoot.FindNode(containingType.Span) as TypeDeclarationSyntax ?? containingType;
+
+ // Apply the factory → constructor transformation.
+ // Annotated call-site nodes that live OUTSIDE the transformed type survive
+ // untouched (annotations are preserved by ReplaceNode).
+ var transformedRoot = BuildRootWithConstructor(workingRoot, currentMethod, currentContainingType);
+
+ // Replace annotated invocations — found by annotation, not by span.
+ var finalDeclaringRoot = transformedRoot;
+ foreach (var annEntry in invocationAnnotations)
+ {
+ var ann = annEntry.Key;
+ var argList = annEntry.Value.ArgList;
+ var leading = annEntry.Value.Leading;
+ var trailing = annEntry.Value.Trailing;
+
+ var annotatedInvocation = finalDeclaringRoot
+ .GetAnnotatedNodes(ann)
+ .OfType()
+ .FirstOrDefault();
+
+ if (annotatedInvocation is null)
+ {
+ continue;
+ }
+
+ // Rewrite: instance.FactoryMethod(args) → new ReturnType(args)
+ var newCreation = SyntaxFactory
+ .ObjectCreationExpression(
+ SyntaxFactory.Token(SyntaxKind.NewKeyword)
+ .WithTrailingTrivia(SyntaxFactory.Space),
+ returnTypeSyntax,
+ argList,
+ initializer: null)
+ .WithLeadingTrivia(leading)
+ .WithTrailingTrivia(trailing);
+
+ finalDeclaringRoot = finalDeclaringRoot.ReplaceNode(annotatedInvocation, newCreation);
+ }
+
+ // Replace annotated method groups with lambdas: Class.Method → p => new ReturnType(p)
+ foreach (var annEntry in methodGroupAnnotations)
+ {
+ var ann = annEntry.Key;
+ var leading = annEntry.Value.Leading;
+ var trailing = annEntry.Value.Trailing;
+
+ var annotatedMethodGroup = finalDeclaringRoot
+ .GetAnnotatedNodes(ann)
+ .FirstOrDefault();
+
+ if (annotatedMethodGroup is null)
+ {
+ continue;
+ }
+
+ var lambda = BuildMethodGroupLambda(methodParamNames, returnTypeSyntax)
+ .WithLeadingTrivia(leading)
+ .WithTrailingTrivia(trailing);
+
+ finalDeclaringRoot = finalDeclaringRoot.ReplaceNode(annotatedMethodGroup, lambda);
+ }
+
+ solution = solution.WithDocumentSyntaxRoot(document.Id, finalDeclaringRoot);
+
+ // -----------------------------------------------------------------------
+ // Other caller documents — spans in these roots are still the original
+ // unmodified spans, so the existing end-to-start approach is correct.
+ // -----------------------------------------------------------------------
+ foreach (var kvp in locationsByDoc)
+ {
+ var docId = kvp.Key;
+ if (docId == document.Id)
+ {
+ // Already handled above via annotations.
+ continue;
+ }
+
+ var locations = kvp.Value;
+
+ var callerDoc = solution.GetDocument(docId);
+ if (callerDoc is null)
+ {
+ continue;
+ }
+
+ var callerRoot = await callerDoc.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
+ if (callerRoot is null)
+ {
+ continue;
+ }
+
+ // Process end-to-start so that earlier spans remain valid.
+ var newCallerRoot = callerRoot;
+ foreach (var refLocation in locations.OrderByDescending(l => l.Location.SourceSpan.Start))
+ {
+ var refNode = newCallerRoot.FindNode(refLocation.Location.SourceSpan);
+
+ // Determine the method-reference expression: qualified (Class.Method) or simple (Method).
+ var methodRefExpr = refNode.Parent is MemberAccessExpressionSyntax maExpr && maExpr.Name == refNode
+ ? maExpr
+ : (ExpressionSyntax)refNode;
+
+ if (methodRefExpr.Parent is InvocationExpressionSyntax invocation
+ && invocation.Expression == methodRefExpr)
+ {
+ // Skip conditional-access invocations like x?.FactoryMethod(...)
+ // to avoid producing invalid syntax such as x?.new ReturnType(...).
+ if (invocation.Parent is ConditionalAccessExpressionSyntax)
+ {
+ continue;
+ }
+
+ // Rewrite: Class.Method(args) → new ReturnType(args)
+ var newCreation = SyntaxFactory
+ .ObjectCreationExpression(
+ SyntaxFactory.Token(SyntaxKind.NewKeyword)
+ .WithTrailingTrivia(SyntaxFactory.Space),
+ returnTypeSyntax,
+ invocation.ArgumentList,
+ initializer: null)
+ .WithLeadingTrivia(invocation.GetLeadingTrivia())
+ .WithTrailingTrivia(invocation.GetTrailingTrivia());
+
+ newCallerRoot = newCallerRoot.ReplaceNode(invocation, newCreation);
+ }
+ else
+ {
+ // Method group: Class.Method → p => new ReturnType(p)
+ // Skip nameof(Class.Method): SymbolFinder returns these locations but
+ // replacing them with a lambda would produce invalid C#.
+ if (IsInsideNameOf(methodRefExpr))
+ {
+ continue;
+ }
+
+ var lambda = BuildMethodGroupLambda(methodParamNames, returnTypeSyntax)
+ .WithLeadingTrivia(methodRefExpr.GetLeadingTrivia())
+ .WithTrailingTrivia(methodRefExpr.GetTrailingTrivia());
+
+ newCallerRoot = newCallerRoot.ReplaceNode(methodRefExpr, lambda);
+ }
+ }
+
+ solution = solution.WithDocumentSyntaxRoot(docId, newCallerRoot);
+ }
+
+ return solution;
+ }
+
+ ///
+ /// Core transformation: removes the factory method, inserts an equivalent
+ /// [Projectable] constructor at the same position, and prepends a public
+ /// parameterless constructor when the class does not already have one.
+ ///
+ private static SyntaxNode BuildRootWithConstructor(
+ SyntaxNode root,
+ MethodDeclarationSyntax method,
+ TypeDeclarationSyntax containingType)
+ {
+ var creation = (BaseObjectCreationExpressionSyntax)method.ExpressionBody!.Expression;
+ var initializer = creation.Initializer!;
+
+ // Only support simple object-initializer assignments (Prop = value). If there are
+ // other initializer forms (e.g., collection initializers) or assignments whose RHS
+ // is a nested initializer (e.g. Items = { 1, 2 }), bail out to avoid
+ // producing a constructor that does not preserve behavior.
+ if (initializer.Expressions.Any(
+ e => e is not AssignmentExpressionSyntax { Right: not InitializerExpressionSyntax }))
+ {
+ return root;
+ }
+
+ // Convert each object-initializer assignment (Prop = value) to a statement (Prop = value;).
+ // Two trivia sources must be preserved:
+ // 1. The expression's own trailing trivia (e.g. an inline "// comment") must move onto
+ // the semicolon token so it stays on the same line as the statement terminator.
+ // 2. The separator comma's trailing trivia (e.g. "\r\n ") carries the newline and
+ // indentation that separates adjacent items in the initializer list. That trivia
+ // is lost when we extract only the expressions, so we append it to the semicolon as
+ // well so the next statement starts on its own correctly-indented line.
+ static StatementSyntax ToStatement(AssignmentExpressionSyntax a, SyntaxTriviaList separatorTrailing)
+ {
+ var exprTrailing = a.GetTrailingTrivia();
+ var semicolonTrailing = exprTrailing.AddRange(separatorTrailing);
+ return SyntaxFactory.ExpressionStatement(
+ a.WithoutTrailingTrivia(),
+ SyntaxFactory.Token(SyntaxKind.SemicolonToken).WithTrailingTrivia(semicolonTrailing));
+ }
+
+ var exprs = initializer.Expressions;
+ var statements = new StatementSyntax[exprs.Count];
+ for (var i = 0; i < exprs.Count; i++)
+ {
+ var a = (AssignmentExpressionSyntax)exprs[i];
+ var sepTrivia = i < exprs.SeparatorCount
+ ? exprs.GetSeparator(i).TrailingTrivia
+ : default;
+ statements[i] = ToStatement(a, sepTrivia);
+ }
+
+ var ctorModifiers = GetConstructorModifiers(method);
+
+ var ctor = SyntaxFactory
+ .ConstructorDeclaration(containingType.Identifier.WithoutTrivia())
+ .WithAttributeLists(method.AttributeLists)
+ .WithModifiers(ctorModifiers)
+ .WithParameterList(method.ParameterList)
+ .WithBody(SyntaxFactory.Block(statements))
+ .WithAdditionalAnnotations(Formatter.Annotation)
+ .WithLeadingTrivia(method.GetLeadingTrivia());
+
+ var methodIndex = containingType.Members.IndexOf(method);
+ var newMembers = containingType.Members.RemoveAt(methodIndex);
+ newMembers = newMembers.Insert(Math.Min(methodIndex, newMembers.Count), ctor);
+
+ // Add an explicit parameterless constructor only when the class originally had NO
+ // explicit constructors at all. The C# compiler emits an implicit parameterless
+ // constructor solely in that case (C# spec §10.11.4), and it is always declared public.
+ // If the class already had other user-declared constructors the implicit default was
+ // already suppressed, so we must not introduce a new public overload.
+ var existingCtors = containingType.Members.OfType().ToArray();
+ var hasParamlessCtor = existingCtors.Any(c => c.ParameterList.Parameters.Count == 0);
+ var hadNoExplicitCtors = existingCtors.Length == 0;
+
+ if (!hasParamlessCtor && hadNoExplicitCtors)
+ {
+ // The implicit default ctor is always public (C# spec §10.11.4) regardless of the
+ // factory method's own accessibility, so hard-code public here.
+ var paramlessCtor = SyntaxFactory
+ .ConstructorDeclaration(containingType.Identifier.WithoutTrivia())
+ .WithModifiers(SyntaxFactory.TokenList(
+ SyntaxFactory.Token(SyntaxKind.PublicKeyword)
+ .WithTrailingTrivia(SyntaxFactory.Space)))
+ .WithParameterList(SyntaxFactory.ParameterList())
+ .WithBody(SyntaxFactory.Block())
+ .WithAdditionalAnnotations(Formatter.Annotation)
+ .WithLeadingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed);
+
+ newMembers = newMembers.Insert(0, paramlessCtor);
+ }
+
+ return root.ReplaceNode(containingType, containingType.WithMembers(newMembers));
+ }
+
+ private static SyntaxTokenList GetConstructorModifiers(MethodDeclarationSyntax method)
+ {
+ // Derive constructor modifiers from the factory method, dropping modifiers that are
+ // invalid or meaningless for instance constructors (e.g., static, async, extern, unsafe).
+ var filteredModifiers = method.Modifiers
+ .Where(m =>
+ !m.IsKind(SyntaxKind.StaticKeyword) &&
+ !m.IsKind(SyntaxKind.AsyncKeyword) &&
+ !m.IsKind(SyntaxKind.ExternKeyword) &&
+ !m.IsKind(SyntaxKind.UnsafeKeyword));
+
+ return SyntaxFactory.TokenList(filteredModifiers);
+ }
+
+ ///
+ /// Returns when is the argument of a
+ /// nameof(…) expression.
+ ///
+ /// Roslyn parses nameof(X.Y) as an whose
+ /// callee is an with the text nameof.
+ /// still returns such
+ /// locations, but replacing them with a lambda or object-creation expression would produce
+ /// invalid C# — they must be skipped.
+ ///
+ ///
+ private static bool IsInsideNameOf(ExpressionSyntax expr) =>
+ expr.Parent is ArgumentSyntax
+ {
+ Parent: ArgumentListSyntax
+ {
+ Parent: InvocationExpressionSyntax
+ {
+ Expression: IdentifierNameSyntax { Identifier.Text: "nameof" }
+ }
+ }
+ };
+
+ ///
+ /// Builds a lambda expression that wraps a constructor call, for use when a factory
+ /// method is referenced as a method group (e.g. Select(MyType.Create)).
+ ///
+ /// - Single parameter → simple lambda: p => new ReturnType(p)
+ /// - Multiple parameters → parenthesised lambda: (p1, p2) => new ReturnType(p1, p2)
+ ///
+ ///
+ private static LambdaExpressionSyntax BuildMethodGroupLambda(
+ string[] paramNames,
+ TypeSyntax returnTypeSyntax)
+ {
+ var parameters = paramNames
+ .Select(name => SyntaxFactory.Parameter(SyntaxFactory.Identifier(name)))
+ .ToArray();
+
+ var arguments = paramNames
+ .Select(name => SyntaxFactory.Argument(SyntaxFactory.IdentifierName(name)))
+ .ToArray();
+
+ var objectCreation = SyntaxFactory.ObjectCreationExpression(
+ SyntaxFactory.Token(SyntaxKind.NewKeyword).WithTrailingTrivia(SyntaxFactory.Space),
+ returnTypeSyntax,
+ SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)),
+ initializer: null);
+
+ if (parameters.Length == 1)
+ {
+ return SyntaxFactory
+ .SimpleLambdaExpression(parameters[0], objectCreation)
+ .WithAdditionalAnnotations(Formatter.Annotation);
+ }
+
+ return SyntaxFactory
+ .ParenthesizedLambdaExpression(
+ SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(parameters)),
+ objectCreation)
+ .WithAdditionalAnnotations(Formatter.Annotation);
+ }
+}
+
diff --git a/src/EntityFrameworkCore.Projectables.CodeFixes/ProjectableCodeFixHelper.cs b/src/EntityFrameworkCore.Projectables.CodeFixes/ProjectableCodeFixHelper.cs
index 0821e64..c06cd6c 100644
--- a/src/EntityFrameworkCore.Projectables.CodeFixes/ProjectableCodeFixHelper.cs
+++ b/src/EntityFrameworkCore.Projectables.CodeFixes/ProjectableCodeFixHelper.cs
@@ -21,6 +21,30 @@ static internal bool TryFindProjectableAttribute(MemberDeclarationSyntax member,
return attribute is not null;
}
+
+ static internal bool TryGetFixableFactoryMethodPattern(
+ SyntaxNode methodNode,
+ out TypeDeclarationSyntax? containingType,
+ out MethodDeclarationSyntax? method)
+ {
+ containingType = null;
+ method = null;
+
+ var localMethod = methodNode.AncestorsAndSelf().OfType().FirstOrDefault();
+ if (localMethod is null)
+ {
+ return false;
+ }
+
+ if (!SyntaxHelpers.TryGetFactoryMethodPattern(localMethod, out containingType))
+ {
+ return false;
+ }
+
+ method = localMethod;
+
+ return TryFindProjectableAttribute(localMethod, out _);
+ }
///
/// Adds or replaces a named argument on the [Projectable] attribute of .
diff --git a/src/EntityFrameworkCore.Projectables.CodeFixes/SyntaxHelpers.cs b/src/EntityFrameworkCore.Projectables.CodeFixes/SyntaxHelpers.cs
new file mode 100644
index 0000000..5c4a82f
--- /dev/null
+++ b/src/EntityFrameworkCore.Projectables.CodeFixes/SyntaxHelpers.cs
@@ -0,0 +1,113 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace EntityFrameworkCore.Projectables.CodeFixes;
+
+static internal class SyntaxHelpers
+{
+ ///
+ /// Returns when matches the
+ /// factory-method pattern:
+ ///
+ /// - Expression body of the form => new ContainingType { … }
+ /// (object initializer only, no constructor arguments in the new
+ /// expression).
+ /// - Static method.
+ /// - Return type simple name equals the containing class name.
+ /// - For explicit new T { … }, T must be an unqualified
+ /// identifier that matches the containing class name. Qualified names such as
+ /// new Other.MyObj { … } or new global::Other.MyObj { … } are
+ /// rejected because they cannot be confirmed as the same type without a semantic
+ /// model.
+ ///
+ ///
+ static internal bool TryGetFactoryMethodPattern(
+ MethodDeclarationSyntax method,
+ out TypeDeclarationSyntax? containingType)
+ {
+ containingType = null;
+
+ if (method.Parent is not TypeDeclarationSyntax parentType)
+ {
+ return false;
+ }
+
+ if (method.ExpressionBody is null)
+ {
+ return false;
+ }
+
+ if (method.ExpressionBody.Expression is not BaseObjectCreationExpressionSyntax creation)
+ {
+ return false;
+ }
+
+ // Only pure object-initializer bodies — no constructor arguments on the new expression.
+ if (creation.ArgumentList?.Arguments.Count > 0)
+ {
+ return false;
+ }
+
+ if (creation.Initializer is null)
+ {
+ return false;
+ }
+
+ // Only pure simple-assignment initializers (Prop = value) — no bare collection
+ // elements (which are not AssignmentExpressionSyntax) and no nested initializer
+ // assignments (Items = { 1, 2 }) whose RHS is an InitializerExpressionSyntax.
+ // Converting such entries to statements would produce invalid C#, so we must not
+ // offer the refactoring at all for these patterns.
+ if (creation.Initializer.Expressions.Any(
+ e => e is not AssignmentExpressionSyntax { Right: not InitializerExpressionSyntax }))
+ {
+ return false;
+ }
+
+ // Only allow static factory methods, to keep the code fix simpler
+ if (!method.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword)))
+ {
+ return false;
+ }
+
+ // For explicit new T { … }, verify the type is an unqualified name matching the
+ // containing class. Qualified names (new Other.MyObj { }, new global::Other.MyObj { })
+ // are rejected: without a semantic model we cannot confirm they resolve to the same
+ // type, so this is the conservative safe choice.
+ // For implicit new() { }, the compiler infers the type from the method's return type,
+ // which is already validated below, so no additional check is needed here.
+ if (creation is ObjectCreationExpressionSyntax { Type: var createdType })
+ {
+ var createdTypeName = createdType switch
+ {
+ IdentifierNameSyntax id => id.Identifier.Text,
+ GenericNameSyntax generic => generic.Identifier.Text,
+ _ => null // QualifiedNameSyntax, AliasQualifiedNameSyntax, etc. — reject
+ };
+
+ if (createdTypeName is null || createdTypeName != parentType.Identifier.Text)
+ {
+ return false;
+ }
+ }
+
+ // The method's return type must match the containing type (syntax-level name comparison).
+ var returnTypeName = method.ReturnType switch
+ {
+ IdentifierNameSyntax id => id.Identifier.Text,
+ QualifiedNameSyntax { Right: IdentifierNameSyntax right } => right.Identifier.Text,
+ GenericNameSyntax generic => generic.Identifier.Text,
+ _ => null
+ };
+
+ if (returnTypeName is null || returnTypeName != parentType.Identifier.Text)
+ {
+ return false;
+ }
+
+ containingType = parentType;
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md b/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md
index c148638..5c81204 100644
--- a/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md
+++ b/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md
@@ -13,6 +13,7 @@ EFP0008 | Design | Error | Target class is missing a parameterless construc
EFP0009 | Design | Error | Delegated constructor cannot be analyzed for projection
EFP0010 | Design | Error | UseMemberBody target member not found
EFP0011 | Design | Error | UseMemberBody target member is incompatible
+EFP0012 | Design | Info | [Projectable] factory method can be converted to a constructor
### Changed Rules
diff --git a/src/EntityFrameworkCore.Projectables.Generator/EntityFrameworkCore.Projectables.Generator.csproj b/src/EntityFrameworkCore.Projectables.Generator/EntityFrameworkCore.Projectables.Generator.csproj
index af7739e..d3a9683 100644
--- a/src/EntityFrameworkCore.Projectables.Generator/EntityFrameworkCore.Projectables.Generator.csproj
+++ b/src/EntityFrameworkCore.Projectables.Generator/EntityFrameworkCore.Projectables.Generator.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs b/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs
index 17bd189..9fab2f2 100644
--- a/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs
+++ b/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs
@@ -91,4 +91,12 @@ static internal class Diagnostics
category: "Design",
DiagnosticSeverity.Error,
isEnabledByDefault: true);
+
+ public readonly static DiagnosticDescriptor FactoryMethodShouldBeConstructor = new DiagnosticDescriptor(
+ id: "EFP0012",
+ title: "[Projectable] factory method can be converted to a constructor",
+ messageFormat: "Factory method '{0}' creates and returns an instance of the containing class via object initializer. Consider converting it to a [Projectable] constructor.",
+ category: "Design",
+ DiagnosticSeverity.Info,
+ isEnabledByDefault: true);
}
\ No newline at end of file
diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs
index a408787..38821b1 100644
--- a/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs
+++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs
@@ -5,6 +5,7 @@
using Microsoft.CodeAnalysis.Text;
using System.Collections.Immutable;
using System.Text;
+using EntityFrameworkCore.Projectables.CodeFixes;
using EntityFrameworkCore.Projectables.Generator.Comparers;
using EntityFrameworkCore.Projectables.Generator.Interpretation;
using EntityFrameworkCore.Projectables.Generator.Models;
@@ -184,6 +185,15 @@ private static void Execute(
{
throw new InvalidOperationException("Expected a memberName here");
}
+
+ // Report EFP0012 when a [Projectable] method is a factory that could be a constructor.
+ if (member is MethodDeclarationSyntax factoryCandidate && SyntaxHelpers.TryGetFactoryMethodPattern(factoryCandidate, out _))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Infrastructure.Diagnostics.FactoryMethodShouldBeConstructor,
+ factoryCandidate.Identifier.GetLocation(),
+ factoryCandidate.Identifier.Text));
+ }
var generatedClassName = ProjectionExpressionClassNameGenerator.GenerateName(projectable.ClassNamespace, projectable.NestedInClassNames, projectable.MemberName, projectable.ParameterTypeNames);
var generatedFileName = projectable.ClassTypeParameterList is not null ? $"{generatedClassName}-{projectable.ClassTypeParameterList.ChildNodes().Count()}.g.cs" : $"{generatedClassName}.g.cs";
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/CodeFixTestBase.cs b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/CodeFixTestBase.cs
index 9bc523b..e0ab23c 100644
--- a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/CodeFixTestBase.cs
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/CodeFixTestBase.cs
@@ -15,7 +15,7 @@ namespace EntityFrameworkCore.Projectables.CodeFixes.Tests;
///
public abstract class CodeFixTestBase
{
- private static Document CreateDocument(string source)
+ protected static Document CreateDocument([StringSyntax("csharp")] string source)
{
var workspace = new AdhocWorkspace();
var projectId = ProjectId.CreateNewId();
@@ -31,8 +31,37 @@ private static Document CreateDocument(string source)
return solution.GetDocument(documentId)!;
}
+ ///
+ /// Like , but also adds all trusted platform assemblies
+ /// so that the workspace's semantic model can resolve symbols fully.
+ /// Required by refactoring actions that call
+ /// SymbolFinder.FindReferencesAsync (e.g. "update callers").
+ ///
+ protected static Document CreateDocumentWithReferences([StringSyntax("csharp")] string source)
+ {
+ var workspace = new AdhocWorkspace();
+ var projectId = ProjectId.CreateNewId();
+ var documentId = DocumentId.CreateNewId(projectId);
+
+ var trustedAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? string.Empty)
+ .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
+ .Select(p => MetadataReference.CreateFromFile(p))
+ .Cast()
+ .ToList();
+
+ var solution = workspace.CurrentSolution
+ .AddProject(projectId, "TestProject", "TestProject", LanguageNames.CSharp)
+ .WithProjectCompilationOptions(
+ projectId,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
+ .WithProjectMetadataReferences(projectId, trustedAssemblies)
+ .AddDocument(documentId, "Test.cs", SourceText.From(source));
+
+ return solution.GetDocument(documentId)!;
+ }
+
private async static Task<(Document Document, IReadOnlyList Actions)> CollectActionsAsync(
- string source,
+ [StringSyntax("csharp")] string source,
string diagnosticId,
Func locateDiagnosticSpan,
CodeFixProvider provider)
@@ -70,7 +99,7 @@ private static Document CreateDocument(string source)
/// returned by .
///
protected async static Task> GetCodeFixActionsAsync(
- string source,
+ [StringSyntax("csharp")] string source,
string diagnosticId,
Func locateDiagnosticSpan,
CodeFixProvider provider)
@@ -84,8 +113,7 @@ protected async static Task> GetCodeFixActionsAsync(
/// source text of the resulting document.
///
protected async static Task ApplyCodeFixAsync(
- [StringSyntax("csharp")]
- string source,
+ [StringSyntax("csharp")] string source,
string diagnosticId,
Func locateDiagnosticSpan,
CodeFixProvider provider,
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_AddsParameterlessConstructor_WhenNoneExists.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_AddsParameterlessConstructor_WhenNoneExists.verified.txt
new file mode 100644
index 0000000..57a096b
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_AddsParameterlessConstructor_WhenNoneExists.verified.txt
@@ -0,0 +1,14 @@
+
+namespace Foo {
+ class Input { }
+ class Output {
+ public Output()
+ {
+ }
+
+ [Projectable]
+ public Output(Input i)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt
new file mode 100644
index 0000000..d460104
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt
@@ -0,0 +1,11 @@
+
+namespace Foo {
+ class Input { }
+ class Output {
+ public Output() { }
+ [Projectable]
+ public Output(Input i)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt
new file mode 100644
index 0000000..ad2d0cc
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt
@@ -0,0 +1,13 @@
+
+namespace Foo {
+ class Input { public int Value { get; set; } }
+ class Output {
+ public int Value { get; set; }
+ public Output(string name) { }
+ [Projectable]
+ public Output(Input i)
+ {
+ Value = i.Value;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_ImplicitObjectCreation.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_ImplicitObjectCreation.verified.txt
new file mode 100644
index 0000000..96789f6
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_ImplicitObjectCreation.verified.txt
@@ -0,0 +1,16 @@
+
+namespace Foo {
+ class OtherObj { public string Prop1 { get; set; } }
+ class MyObj {
+ public MyObj()
+ {
+ }
+
+ public string Prop1 { get; set; }
+ [Projectable]
+ public MyObj(OtherObj obj)
+ {
+ Prop1 = obj.Prop1;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_InsertedParameterlessCtorIsAlwaysPublic.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_InsertedParameterlessCtorIsAlwaysPublic.verified.txt
new file mode 100644
index 0000000..4850265
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_InsertedParameterlessCtorIsAlwaysPublic.verified.txt
@@ -0,0 +1,15 @@
+
+namespace Foo {
+ class Input { }
+ class Output {
+ public Output()
+ {
+ }
+
+ public int Value { get; set; }
+ [Projectable]
+ internal Output(Input i)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_PreservesProjectableOptions.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_PreservesProjectableOptions.verified.txt
new file mode 100644
index 0000000..c6e87af
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_PreservesProjectableOptions.verified.txt
@@ -0,0 +1,14 @@
+
+namespace Foo {
+ class OtherObj { }
+ class MyObj {
+ public MyObj()
+ {
+ }
+
+ [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)]
+ public MyObj(OtherObj obj)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_SimpleStaticFactoryMethod.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_SimpleStaticFactoryMethod.verified.txt
new file mode 100644
index 0000000..96789f6
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_SimpleStaticFactoryMethod.verified.txt
@@ -0,0 +1,16 @@
+
+namespace Foo {
+ class OtherObj { public string Prop1 { get; set; } }
+ class MyObj {
+ public MyObj()
+ {
+ }
+
+ public string Prop1 { get; set; }
+ [Projectable]
+ public MyObj(OtherObj obj)
+ {
+ Prop1 = obj.Prop1;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.cs b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.cs
new file mode 100644
index 0000000..f5a5744
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.cs
@@ -0,0 +1,185 @@
+namespace EntityFrameworkCore.Projectables.CodeFixes.Tests;
+
+///
+/// Tests for (EFP0012).
+/// Verifies the code fix output via Verify.Xunit snapshots.
+///
+[UsesVerify]
+public class FactoryMethodToCtorCodeFixProviderTests : CodeFixTestBase
+{
+ private readonly static FactoryMethodToCtorCodeFixProvider _provider = new();
+
+ [Fact]
+ public void FixableDiagnosticIds_ContainsEFP0012() =>
+ Assert.Contains("EFP0012", _provider.FixableDiagnosticIds);
+
+ [Fact]
+ public Task CodeFix_SimpleStaticFactoryMethod() =>
+ Verifier.Verify(
+ ApplyCodeFixAsync(
+ FactoryMethodToCtorSources.SimpleStaticFactoryMethod,
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public Task CodeFix_PreservesProjectableOptions() =>
+ Verifier.Verify(
+ ApplyCodeFixAsync(
+ FactoryMethodToCtorSources.PreservesProjectableOptions,
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public Task CodeFix_AddsParameterlessConstructor_WhenNoneExists() =>
+ Verifier.Verify(
+ ApplyCodeFixAsync(
+ FactoryMethodToCtorSources.AddsParameterlessConstructor,
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public Task CodeFix_DoesNotAddParamLessCtor_WhenAlreadyPresent() =>
+ Verifier.Verify(
+ ApplyCodeFixAsync(
+ FactoryMethodToCtorSources.ParameterlessConstructorAlreadyPresent,
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ ///
+ /// When the class already has at least one explicit constructor (but no parameterless one),
+ /// the C# compiler did NOT generate an implicit default constructor — so the transformation
+ /// must NOT insert one, which would unintentionally widen the public surface area.
+ ///
+ [Fact]
+ public Task CodeFix_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists() =>
+ Verifier.Verify(
+ ApplyCodeFixAsync(
+ FactoryMethodToCtorSources.OtherExplicitCtorExists,
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ ///
+ /// The implicit default constructor is always public (C# spec §10.11.4) regardless of the
+ /// factory method's accessibility. The inserted explicit parameterless ctor must therefore
+ /// be public too, even when the factory method is internal or protected.
+ ///
+ [Fact]
+ public Task CodeFix_InsertedParameterlessCtorIsAlwaysPublic() =>
+ Verifier.Verify(
+ ApplyCodeFixAsync(
+ FactoryMethodToCtorSources.InsertedParameterlessCtorIsAlwaysPublic,
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ ///
+ /// Regression test: implicit object creation (new() { … }) must not throw
+ /// an —
+ /// must treat it as
+ /// rather than casting to the explicit ObjectCreationExpressionSyntax.
+ ///
+ [Fact]
+ public Task CodeFix_ImplicitObjectCreation() =>
+ Verifier.Verify(
+ ApplyCodeFixAsync(
+ FactoryMethodToCtorSources.ImplicitObjectCreation,
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public async Task TwoCodeFixActionsAreOffered()
+ {
+ var actions = await GetCodeFixActionsAsync(
+ FactoryMethodToCtorSources.TwoActionsSource,
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Equal(2, actions.Count);
+ Assert.Contains("constructor", actions[0].Title, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("callers", actions[1].Title, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task NoCodeFix_WhenPatternDoesNotMatch()
+ {
+ // Without [Projectable] the pattern check returns false → no fix registered.
+ var actions = await GetCodeFixActionsAsync(
+ @"
+namespace Foo {
+ class MyObj {
+ public static MyObj Create() => new MyObj { };
+ }
+}",
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ ///
+ /// Items = { 1, 2 } in an object initializer is an
+ ///
+ /// whose RHS is an .
+ /// Converting it to a statement produces invalid C# (Items = { 1, 2 };),
+ /// so the code fix must not be offered for this pattern.
+ ///
+ [Fact]
+ public async Task NoCodeFix_WhenInitializerHasNestedCollectionInitializer()
+ {
+ var actions = await GetCodeFixActionsAsync(
+ @"
+using System.Collections.Generic;
+namespace Foo {
+ class MyObj {
+ public List Items { get; set; }
+ [Projectable]
+ public static MyObj Create() => new MyObj { Items = { 1, 2 } };
+ }
+}",
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ ///
+ /// A mixed initializer that combines a simple assignment with a nested collection
+ /// initializer (Items = { 1, 2 }) must also be rejected.
+ ///
+ [Fact]
+ public async Task NoCodeFix_WhenMixedSimpleAndNestedCollectionInitializer()
+ {
+ var actions = await GetCodeFixActionsAsync(
+ @"
+using System.Collections.Generic;
+namespace Foo {
+ class MyObj {
+ public int Value { get; set; }
+ public List Items { get; set; }
+ [Projectable]
+ public static MyObj Create(int v) => new MyObj { Value = v, Items = { 1, 2 } };
+ }
+}",
+ "EFP0012",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+}
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_AddsParamLessCtor_WhenNoneExists.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_AddsParamLessCtor_WhenNoneExists.verified.txt
new file mode 100644
index 0000000..57a096b
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_AddsParamLessCtor_WhenNoneExists.verified.txt
@@ -0,0 +1,14 @@
+
+namespace Foo {
+ class Input { }
+ class Output {
+ public Output()
+ {
+ }
+
+ [Projectable]
+ public Output(Input i)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ComplexPropertyExpressions_ArePreserved.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ComplexPropertyExpressions_ArePreserved.verified.txt
new file mode 100644
index 0000000..603d167
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ComplexPropertyExpressions_ArePreserved.verified.txt
@@ -0,0 +1,25 @@
+
+namespace Foo {
+ class Src {
+ public int X { get; set; }
+ public int Y { get; set; }
+ public bool IsActive { get; set; }
+ public string Name { get; set; }
+ }
+ class Dest {
+ public Dest()
+ {
+ }
+
+ public int Sum { get; set; }
+ public int Toggle { get; set; }
+ public string Label { get; set; }
+ [Projectable]
+ public Dest(Src src)
+ {
+ Sum = src.X + src.Y;
+ Toggle = src.IsActive ? 1 : 0;
+ Label = src.Name ?? "unknown";
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt
new file mode 100644
index 0000000..d460104
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt
@@ -0,0 +1,11 @@
+
+namespace Foo {
+ class Input { }
+ class Output {
+ public Output() { }
+ [Projectable]
+ public Output(Input i)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt
new file mode 100644
index 0000000..ad2d0cc
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt
@@ -0,0 +1,13 @@
+
+namespace Foo {
+ class Input { public int Value { get; set; } }
+ class Output {
+ public int Value { get; set; }
+ public Output(string name) { }
+ [Projectable]
+ public Output(Input i)
+ {
+ Value = i.Value;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ImplicitObjectCreation.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ImplicitObjectCreation.verified.txt
new file mode 100644
index 0000000..96789f6
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ImplicitObjectCreation.verified.txt
@@ -0,0 +1,16 @@
+
+namespace Foo {
+ class OtherObj { public string Prop1 { get; set; } }
+ class MyObj {
+ public MyObj()
+ {
+ }
+
+ public string Prop1 { get; set; }
+ [Projectable]
+ public MyObj(OtherObj obj)
+ {
+ Prop1 = obj.Prop1;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_InsertedParameterlessCtorIsAlwaysPublic.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_InsertedParameterlessCtorIsAlwaysPublic.verified.txt
new file mode 100644
index 0000000..4850265
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_InsertedParameterlessCtorIsAlwaysPublic.verified.txt
@@ -0,0 +1,15 @@
+
+namespace Foo {
+ class Input { }
+ class Output {
+ public Output()
+ {
+ }
+
+ public int Value { get; set; }
+ [Projectable]
+ internal Output(Input i)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_MultipleInitializerAssignments.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_MultipleInitializerAssignments.verified.txt
new file mode 100644
index 0000000..28bfeea
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_MultipleInitializerAssignments.verified.txt
@@ -0,0 +1,17 @@
+
+namespace Foo {
+ class Src { public int A { get; set; } public int B { get; set; } }
+ class Dest {
+ public Dest()
+ {
+ }
+
+ public int A { get; set; }
+ public int B { get; set; }
+ [Projectable]
+ public Dest(Src src)
+ {
+ A = src.A; B = src.B;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesInitializerInlineComments.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesInitializerInlineComments.verified.txt
new file mode 100644
index 0000000..6a2348c
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesInitializerInlineComments.verified.txt
@@ -0,0 +1,19 @@
+
+namespace Foo {
+ class Src { public int A { get; set; } public int B { get; set; } }
+ class Dest {
+ public Dest()
+ {
+ }
+
+ public int A { get; set; }
+ public int B { get; set; }
+ [Projectable]
+ public Dest(Src src)
+ {
+ // primary field
+ A = src.A;
+ B = src.B; // secondary field
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesLeadingXmlDocComment.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesLeadingXmlDocComment.verified.txt
new file mode 100644
index 0000000..b3b81b9
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesLeadingXmlDocComment.verified.txt
@@ -0,0 +1,18 @@
+
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest()
+ {
+ }
+
+ public int A { get; set; }
+ /// Creates a new from a .
+ /// The source object.
+ [Projectable]
+ public Dest(Src src)
+ {
+ A = src.A;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesProjectableOptions.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesProjectableOptions.verified.txt
new file mode 100644
index 0000000..c6e87af
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesProjectableOptions.verified.txt
@@ -0,0 +1,14 @@
+
+namespace Foo {
+ class OtherObj { }
+ class MyObj {
+ public MyObj()
+ {
+ }
+
+ [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)]
+ public MyObj(OtherObj obj)
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_SimpleFactoryMethod.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_SimpleFactoryMethod.verified.txt
new file mode 100644
index 0000000..96789f6
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_SimpleFactoryMethod.verified.txt
@@ -0,0 +1,16 @@
+
+namespace Foo {
+ class OtherObj { public string Prop1 { get; set; } }
+ class MyObj {
+ public MyObj()
+ {
+ }
+
+ public string Prop1 { get; set; }
+ [Projectable]
+ public MyObj(OtherObj obj)
+ {
+ Prop1 = obj.Prop1;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_WithExistingMembers_PreservesMemberOrder.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_WithExistingMembers_PreservesMemberOrder.verified.txt
new file mode 100644
index 0000000..82d32e6
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_WithExistingMembers_PreservesMemberOrder.verified.txt
@@ -0,0 +1,17 @@
+
+namespace Foo {
+ class Input { public int Value { get; set; } }
+ class Output {
+ public Output()
+ {
+ }
+
+ public int Value { get; set; }
+ public string Name { get; set; }
+ [Projectable]
+ public Output(Input i)
+ {
+ Value = i.Value;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_MultipleParameters.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_MultipleParameters.verified.txt
new file mode 100644
index 0000000..c504378
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_MultipleParameters.verified.txt
@@ -0,0 +1,19 @@
+
+using System;
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ public int Offset { get; set; }
+ [Projectable]
+ public Dest(Src src, int offset)
+ {
+ A = src.A; Offset = offset;
+ }
+ }
+ class Consumer {
+ void Register(Func factory) { }
+ void Setup() => Register((src, offset) => new Dest(src, offset));
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_SingleParameter.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_SingleParameter.verified.txt
new file mode 100644
index 0000000..5d3fa21
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_SingleParameter.verified.txt
@@ -0,0 +1,18 @@
+
+using System.Collections.Generic;
+using System.Linq;
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ [Projectable]
+ public Dest(Src src)
+ {
+ A = src.A;
+ }
+ }
+ class Consumer {
+ Dest[] Use(IEnumerable items) => items.Select(src => new Dest(src)).ToArray();
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MixedDirectInvocationAndMethodGroup.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MixedDirectInvocationAndMethodGroup.verified.txt
new file mode 100644
index 0000000..f6a1c1d
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MixedDirectInvocationAndMethodGroup.verified.txt
@@ -0,0 +1,21 @@
+
+using System.Collections.Generic;
+using System.Linq;
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ [Projectable]
+ public Dest(Src src)
+ {
+ A = src.A;
+ }
+ }
+ class Consumer {
+ void Setup(IEnumerable items) {
+ var d = new Dest(new Src { A = 1 }); // direct invocation
+ var all = items.Select(src => new Dest(src)).ToArray(); // method group
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_NameOfReference_IsNotRewritten.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_NameOfReference_IsNotRewritten.verified.txt
new file mode 100644
index 0000000..6acb34a
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_NameOfReference_IsNotRewritten.verified.txt
@@ -0,0 +1,16 @@
+
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ [Projectable]
+ public Dest(Src src)
+ {
+ A = src.A;
+ }
+ }
+ class Consumer {
+ string GetMethodName() => nameof(Dest.Map);
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_PreservesCallSiteTrivia.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_PreservesCallSiteTrivia.verified.txt
new file mode 100644
index 0000000..397a09f
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_PreservesCallSiteTrivia.verified.txt
@@ -0,0 +1,19 @@
+
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ [Projectable]
+ public Dest(Src src)
+ {
+ A = src.A;
+ }
+ }
+ class Consumer {
+ Dest Use(Src src) {
+ // map the source
+ return new Dest(src); // inline comment
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_SameDocument_OnlyReplacesFactoryCallSite.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_SameDocument_OnlyReplacesFactoryCallSite.verified.txt
new file mode 100644
index 0000000..0f7363c
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_SameDocument_OnlyReplacesFactoryCallSite.verified.txt
@@ -0,0 +1,23 @@
+
+namespace Foo {
+ class Src { public int A { get; set; } public int B { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ public int B { get; set; }
+ [Projectable]
+ public Dest(Src src)
+ {
+ A = src.A; B = src.B;
+ }
+ }
+ class Other {
+ public static int Compute() => 42;
+ }
+ class Consumer {
+ void Setup() {
+ var d = new Dest(new Src { A = 1, B = 2 });
+ var x = Other.Compute();
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.cs b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.cs
new file mode 100644
index 0000000..78ee2ad
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.cs
@@ -0,0 +1,587 @@
+using VerifyXunit;
+using Xunit;
+
+namespace EntityFrameworkCore.Projectables.CodeFixes.Tests;
+
+///
+/// Tests for .
+/// Each test verifies via Verify.Xunit snapshots (.verified.txt files).
+///
+[UsesVerify]
+public class FactoryMethodToCtorCodeRefProviderTests : RefactoringTestBase
+{
+ private readonly static FactoryMethodToConstructorCodeRefactoringProvider _provider = new();
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // Action 0 — convert factory method to constructor (document only)
+ // ────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public Task ConvertToConstructor_SimpleFactoryMethod() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ FactoryMethodToCtorSources.SimpleStaticFactoryMethod,
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public Task ConvertToConstructor_PreservesProjectableOptions() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ FactoryMethodToCtorSources.PreservesProjectableOptions,
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public Task ConvertToConstructor_MultipleInitializerAssignments() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ @"
+namespace Foo {
+ class Src { public int A { get; set; } public int B { get; set; } }
+ class Dest {
+ public int A { get; set; }
+ public int B { get; set; }
+ [Projectable]
+ public static Dest Map(Src src) => new Dest { A = src.A, B = src.B };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public Task ConvertToConstructor_AddsParamLessCtor_WhenNoneExists() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ FactoryMethodToCtorSources.AddsParameterlessConstructor,
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public Task ConvertToConstructor_DoesNotAddParamLessCtor_WhenAlreadyPresent() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ FactoryMethodToCtorSources.ParameterlessConstructorAlreadyPresent,
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ ///
+ /// When the class already has at least one explicit constructor (but no parameterless one),
+ /// the C# compiler did NOT generate an implicit default constructor — so the transformation
+ /// must NOT insert one, which would unintentionally widen the public surface area.
+ ///
+ [Fact]
+ public Task ConvertToConstructor_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ FactoryMethodToCtorSources.OtherExplicitCtorExists,
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ ///
+ /// The implicit default constructor is always public (C# spec §10.11.4) regardless of the
+ /// factory method's accessibility. The inserted explicit parameterless ctor must therefore
+ /// be public too, even when the factory method is internal or protected.
+ ///
+ [Fact]
+ public Task ConvertToConstructor_InsertedParameterlessCtorIsAlwaysPublic() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ FactoryMethodToCtorSources.InsertedParameterlessCtorIsAlwaysPublic,
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public Task ConvertToConstructor_WithExistingMembers_PreservesMemberOrder() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ @"
+namespace Foo {
+ class Input { public int Value { get; set; } }
+ class Output {
+ public int Value { get; set; }
+ public string Name { get; set; }
+ [Projectable]
+ public static Output From(Input i) => new Output { Value = i.Value };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // Guard: no refactoring should be offered in inapplicable situations
+ // ────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task NoRefactoring_WhenMethodHasNoProjectableAttribute()
+ {
+ var actions = await GetRefactoringActionsAsync(
+ @"
+namespace Foo {
+ class MyObj {
+ public MyObj Create() => new MyObj { };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ [Fact]
+ public async Task NoRefactoring_WhenReturnTypeDoesNotMatchContainingClass()
+ {
+ var actions = await GetRefactoringActionsAsync(
+ @"
+namespace Foo {
+ class Other { }
+ class MyObj {
+ [Projectable]
+ public Other Create() => new Other { };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ [Fact]
+ public async Task NoRefactoring_WhenBodyHasConstructorArguments()
+ {
+ var actions = await GetRefactoringActionsAsync(
+ @"
+namespace Foo {
+ class MyObj {
+ [Projectable]
+ public MyObj Create(int x) => new MyObj(x) { };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ [Fact]
+ public async Task NoRefactoring_WhenBodyIsNotObjectCreation()
+ {
+ var actions = await GetRefactoringActionsAsync(
+ @"
+namespace Foo {
+ class MyObj {
+ [Projectable]
+ public MyObj Create() => default;
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ [Fact]
+ public async Task NoRefactoring_WhenBodyHasNoInitializer()
+ {
+ var actions = await GetRefactoringActionsAsync(
+ @"
+namespace Foo {
+ class MyObj {
+ [Projectable]
+ public MyObj Create() => new MyObj();
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ ///
+ /// Items = { 1, 2 } in an object initializer is an
+ ///
+ /// whose RHS is an .
+ /// Converting it to a statement produces invalid C# (Items = { 1, 2 };),
+ /// so the refactoring must not be offered for this pattern.
+ ///
+ [Fact]
+ public async Task NoRefactoring_WhenInitializerHasNestedCollectionInitializer()
+ {
+ var actions = await GetRefactoringActionsAsync(
+ @"
+using System.Collections.Generic;
+namespace Foo {
+ class MyObj {
+ public List Items { get; set; }
+ [Projectable]
+ public static MyObj Create() => new MyObj { Items = { 1, 2 } };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ ///
+ /// A mixed initializer that combines a simple assignment with a nested collection
+ /// initializer (Items = { 1, 2 }) must also be rejected.
+ ///
+ [Fact]
+ public async Task NoRefactoring_WhenMixedSimpleAndNestedCollectionInitializer()
+ {
+ var actions = await GetRefactoringActionsAsync(
+ @"
+using System.Collections.Generic;
+namespace Foo {
+ class MyObj {
+ public int Value { get; set; }
+ public List Items { get; set; }
+ [Projectable]
+ public static MyObj Create(int v) => new MyObj { Value = v, Items = { 1, 2 } };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ ///
+ /// Regression: new Other.MyObj { } has a qualified type name that cannot be
+ /// confirmed as the containing type without a semantic model. The pattern must reject
+ /// it to avoid a false-positive transformation that would corrupt the class.
+ ///
+ [Fact]
+ public async Task NoRefactoring_WhenCreatedTypeIsQualifiedName()
+ {
+ var actions = await GetRefactoringActionsAsync(
+ @"
+namespace Other { class MyObj { } }
+namespace Foo {
+ class MyObj {
+ [Projectable]
+ public static MyObj Create() => new Other.MyObj { };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ ///
+ /// Regression: new global::Other.MyObj { } uses an alias-qualified name that
+ /// cannot be confirmed as the containing type without a semantic model.
+ ///
+ [Fact]
+ public async Task NoRefactoring_WhenCreatedTypeIsAliasQualifiedName()
+ {
+ var actions = await GetRefactoringActionsAsync(
+ @"
+namespace Other { class MyObj { } }
+namespace Foo {
+ class MyObj {
+ [Projectable]
+ public static MyObj Create() => new global::Other.MyObj { };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Empty(actions);
+ }
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // Action titles
+ // ────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task TwoActionsAreOffered_WithCorrectTitles()
+ {
+ var actions = await GetRefactoringActionsAsync(
+ FactoryMethodToCtorSources.TwoActionsSource,
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider);
+
+ Assert.Equal(2, actions.Count);
+ Assert.Contains("constructor", actions[0].Title, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("callers", actions[1].Title, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // Action 1 — convert factory method to constructor AND update callers
+ // ────────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Regression test: when the declaring document also contains call sites,
+ /// BuildRootWithConstructor shifts all spans (it removes the factory method and
+ /// inserts a constructor). Only Dest.Map(…) must be rewritten —
+ /// unrelated invocations such as Other.Compute() must be left intact.
+ ///
+ [Fact]
+ public Task UpdateCallers_SameDocument_OnlyReplacesFactoryCallSite() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ CreateDocumentWithReferences(@"
+namespace Foo {
+ class Src { public int A { get; set; } public int B { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ public int B { get; set; }
+ [Projectable]
+ public static Dest Map(Src src) => new Dest { A = src.A, B = src.B };
+ }
+ class Other {
+ public static int Compute() => 42;
+ }
+ class Consumer {
+ void Setup() {
+ var d = Dest.Map(new Src { A = 1, B = 2 });
+ var x = Other.Compute();
+ }
+ }
+}"),
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 1));
+
+ [Fact]
+ public Task UpdateCallers_PreservesCallSiteTrivia() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ CreateDocumentWithReferences(@"
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ [Projectable]
+ public static Dest Map(Src src) => new Dest { A = src.A };
+ }
+ class Consumer {
+ Dest Use(Src src) {
+ // map the source
+ return Dest.Map(src); // inline comment
+ }
+ }
+}"),
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 1));
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // Action 1 — method group callers (e.g. .Select(Dest.Map))
+ // ────────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// A single-parameter method group passed to Select must be rewritten to a
+ /// simple lambda: Dest.Map → src => new Dest(src).
+ ///
+ [Fact]
+ public Task UpdateCallers_MethodGroup_SingleParameter() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ CreateDocumentWithReferences(@"
+using System.Collections.Generic;
+using System.Linq;
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ [Projectable]
+ public static Dest Map(Src src) => new Dest { A = src.A };
+ }
+ class Consumer {
+ Dest[] Use(IEnumerable items) => items.Select(Dest.Map).ToArray();
+ }
+}"),
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 1));
+
+ ///
+ /// A multi-parameter method group assigned to a delegate variable must be rewritten
+ /// to a parenthesised lambda: Dest.Map → (src, offset) => new Dest(src, offset).
+ ///
+ [Fact]
+ public Task UpdateCallers_MethodGroup_MultipleParameters() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ CreateDocumentWithReferences(@"
+using System;
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ public int Offset { get; set; }
+ [Projectable]
+ public static Dest Map(Src src, int offset) => new Dest { A = src.A, Offset = offset };
+ }
+ class Consumer {
+ void Register(Func factory) { }
+ void Setup() => Register(Dest.Map);
+ }
+}"),
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 1));
+
+ ///
+ /// When the same document has both a direct invocation and a method-group
+ /// reference to the same factory method, both must be rewritten correctly and
+ /// independently.
+ ///
+ [Fact]
+ public Task UpdateCallers_MixedDirectInvocationAndMethodGroup() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ CreateDocumentWithReferences(@"
+using System.Collections.Generic;
+using System.Linq;
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ [Projectable]
+ public static Dest Map(Src src) => new Dest { A = src.A };
+ }
+ class Consumer {
+ void Setup(IEnumerable items) {
+ var d = Dest.Map(new Src { A = 1 }); // direct invocation
+ var all = items.Select(Dest.Map).ToArray(); // method group
+ }
+ }
+}"),
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 1));
+
+ ///
+ /// nameof(Dest.Map) is returned by SymbolFinder.FindReferencesAsync as a
+ /// reference location. It must NOT be rewritten to a lambda — it should be left unchanged
+ /// (producing a compile-time error that the user can fix manually), rather than generating
+ /// invalid C# like p => new Dest(p) inside a nameof argument.
+ ///
+ [Fact]
+ public Task UpdateCallers_NameOfReference_IsNotRewritten() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ CreateDocumentWithReferences(@"
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public Dest() { }
+ public int A { get; set; }
+ [Projectable]
+ public static Dest Map(Src src) => new Dest { A = src.A };
+ }
+ class Consumer {
+ string GetMethodName() => nameof(Dest.Map);
+ }
+}"),
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 1));
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // Complex initializer expressions and trivia preservation (action 0)
+ // ────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public Task ConvertToConstructor_ComplexPropertyExpressions_ArePreserved() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ @"
+namespace Foo {
+ class Src {
+ public int X { get; set; }
+ public int Y { get; set; }
+ public bool IsActive { get; set; }
+ public string Name { get; set; }
+ }
+ class Dest {
+ public int Sum { get; set; }
+ public int Toggle { get; set; }
+ public string Label { get; set; }
+ [Projectable]
+ public static Dest Map(Src src) => new Dest {
+ Sum = src.X + src.Y,
+ Toggle = src.IsActive ? 1 : 0,
+ Label = src.Name ?? ""unknown""
+ };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public Task ConvertToConstructor_PreservesLeadingXmlDocComment() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ @"
+namespace Foo {
+ class Src { public int A { get; set; } }
+ class Dest {
+ public int A { get; set; }
+ /// Creates a new from a .
+ /// The source object.
+ [Projectable]
+ public static Dest Map(Src src) => new Dest { A = src.A };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ ///
+ /// Regression test: implicit object creation (new() { … }) must not throw
+ /// an —
+ /// must treat it as
+ /// rather than casting to the explicit ObjectCreationExpressionSyntax.
+ ///
+ [Fact]
+ public Task ConvertToConstructor_ImplicitObjectCreation() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ FactoryMethodToCtorSources.ImplicitObjectCreation,
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+
+ [Fact]
+ public Task ConvertToConstructor_PreservesInitializerInlineComments() =>
+ Verifier.Verify(
+ ApplyRefactoringAsync(
+ @"
+namespace Foo {
+ class Src { public int A { get; set; } public int B { get; set; } }
+ class Dest {
+ public int A { get; set; }
+ public int B { get; set; }
+ [Projectable]
+ public static Dest Map(Src src) => new Dest {
+ // primary field
+ A = src.A,
+ B = src.B // secondary field
+ };
+ }
+}",
+ FactoryMethodToCtorSources.FirstMethodIdentifierSpan,
+ _provider,
+ actionIndex: 0));
+}
+
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorSources.cs b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorSources.cs
new file mode 100644
index 0000000..904414d
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorSources.cs
@@ -0,0 +1,105 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace EntityFrameworkCore.Projectables.CodeFixes.Tests;
+
+///
+/// Source code snippets shared between
+/// and .
+///
+static internal class FactoryMethodToCtorSources
+{
+ static internal TextSpan FirstMethodIdentifierSpan(SyntaxNode root) =>
+ root.DescendantNodes()
+ .OfType()
+ .First()
+ .Identifier
+ .Span;
+
+ [StringSyntax("csharp")]
+ internal const string SimpleStaticFactoryMethod = @"
+namespace Foo {
+ class OtherObj { public string Prop1 { get; set; } }
+ class MyObj {
+ public string Prop1 { get; set; }
+ [Projectable]
+ public static MyObj Create(OtherObj obj) => new MyObj { Prop1 = obj.Prop1 };
+ }
+}";
+
+ [StringSyntax("csharp")]
+ internal const string PreservesProjectableOptions = @"
+namespace Foo {
+ class OtherObj { }
+ class MyObj {
+ [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)]
+ public static MyObj Create(OtherObj obj) => new MyObj { };
+ }
+}";
+
+ [StringSyntax("csharp")]
+ internal const string AddsParameterlessConstructor = @"
+namespace Foo {
+ class Input { }
+ class Output {
+ [Projectable]
+ public static Output Create(Input i) => new Output { };
+ }
+}";
+
+ [StringSyntax("csharp")]
+ internal const string ParameterlessConstructorAlreadyPresent = @"
+namespace Foo {
+ class Input { }
+ class Output {
+ public Output() { }
+ [Projectable]
+ public static Output Create(Input i) => new Output { };
+ }
+}";
+
+ [StringSyntax("csharp")]
+ internal const string OtherExplicitCtorExists = @"
+namespace Foo {
+ class Input { public int Value { get; set; } }
+ class Output {
+ public int Value { get; set; }
+ public Output(string name) { }
+ [Projectable]
+ public static Output Create(Input i) => new Output { Value = i.Value };
+ }
+}";
+
+ [StringSyntax("csharp")]
+ internal const string InsertedParameterlessCtorIsAlwaysPublic = @"
+namespace Foo {
+ class Input { }
+ class Output {
+ public int Value { get; set; }
+ [Projectable]
+ internal static Output Create(Input i) => new Output { };
+ }
+}";
+
+ [StringSyntax("csharp")]
+ internal const string ImplicitObjectCreation = @"
+namespace Foo {
+ class OtherObj { public string Prop1 { get; set; } }
+ class MyObj {
+ public string Prop1 { get; set; }
+ [Projectable]
+ public static MyObj Create(OtherObj obj) => new() { Prop1 = obj.Prop1 };
+ }
+}";
+
+ [StringSyntax("csharp")]
+ internal const string TwoActionsSource = @"
+namespace Foo {
+ class MyObj {
+ [Projectable]
+ public static MyObj Create() => new MyObj { };
+ }
+}";
+}
\ No newline at end of file
diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/RefactoringTestBase.cs b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/RefactoringTestBase.cs
new file mode 100644
index 0000000..f2d0756
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/RefactoringTestBase.cs
@@ -0,0 +1,113 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeRefactorings;
+using Microsoft.CodeAnalysis.Text;
+using Xunit;
+
+namespace EntityFrameworkCore.Projectables.CodeFixes.Tests;
+
+///
+/// Base class providing helpers for tests.
+/// Builds an in-memory document, invokes the provider at the supplied span, and
+/// optionally applies one of the offered refactoring actions.
+///
+public abstract class RefactoringTestBase : CodeFixTestBase
+{
+ private async static Task<(Document Document, IReadOnlyList Actions)> CollectRefactoringActionsAsync(
+ [StringSyntax("csharp")]
+ string source,
+ Func locateSpan,
+ CodeRefactoringProvider provider)
+ {
+ var document = CreateDocument(source);
+ var root = await document.GetSyntaxRootAsync();
+ var span = locateSpan(root!);
+
+ var actions = new List();
+ var context = new CodeRefactoringContext(
+ document,
+ span,
+ action => actions.Add(action),
+ CancellationToken.None);
+
+ await provider.ComputeRefactoringsAsync(context);
+ return (document, actions);
+ }
+
+ ///
+ /// Returns all instances offered by
+ /// for the span returned by .
+ ///
+ protected async static Task> GetRefactoringActionsAsync(
+ [StringSyntax("csharp")]
+ string source,
+ Func locateSpan,
+ CodeRefactoringProvider provider)
+ {
+ var (_, actions) = await CollectRefactoringActionsAsync(source, locateSpan, provider);
+ return actions;
+ }
+
+ ///
+ /// Applies the refactoring action at and returns the full
+ /// source text of the primary (originating) document after the change.
+ ///
+ protected async static Task ApplyRefactoringAsync(
+ [StringSyntax("csharp")]
+ string source,
+ Func locateSpan,
+ CodeRefactoringProvider provider,
+ int actionIndex = 0)
+ {
+ var (document, actions) = await CollectRefactoringActionsAsync(source, locateSpan, provider);
+
+ Assert.True(
+ actions.Count > actionIndex,
+ $"Expected at least {actionIndex + 1} refactoring action(s) but only {actions.Count} were registered.");
+
+ var action = actions[actionIndex];
+ var operations = await action.GetOperationsAsync(CancellationToken.None);
+ var applyOp = operations.OfType().Single();
+
+ var newDocument = applyOp.ChangedSolution.GetDocument(document.Id)!;
+ var newRoot = await newDocument.GetSyntaxRootAsync();
+ return newRoot!.ToFullString();
+ }
+
+ ///
+ /// Overload that accepts an already-created .
+ /// Use this when the document must carry metadata references (e.g. for
+ /// "update callers" actions that rely on SymbolFinder.FindReferencesAsync).
+ ///
+ protected async static Task ApplyRefactoringAsync(
+ Document document,
+ Func locateSpan,
+ CodeRefactoringProvider provider,
+ int actionIndex = 0)
+ {
+ var root = await document.GetSyntaxRootAsync();
+ var span = locateSpan(root!);
+
+ var actions = new List();
+ var context = new CodeRefactoringContext(
+ document,
+ span,
+ action => actions.Add(action),
+ CancellationToken.None);
+
+ await provider.ComputeRefactoringsAsync(context);
+
+ Assert.True(
+ actions.Count > actionIndex,
+ $"Expected at least {actionIndex + 1} refactoring action(s) but only {actions.Count} were registered.");
+
+ var action = actions[actionIndex];
+ var operations = await action.GetOperationsAsync(CancellationToken.None);
+ var applyOp = operations.OfType().Single();
+
+ var newDocument = applyOp.ChangedSolution.GetDocument(document.Id)!;
+ var newRoot = await newDocument.GetSyntaxRootAsync();
+ return newRoot!.ToFullString();
+ }
+}
diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/FactoryMethodDiagnosticTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/FactoryMethodDiagnosticTests.cs
new file mode 100644
index 0000000..da2c2bf
--- /dev/null
+++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/FactoryMethodDiagnosticTests.cs
@@ -0,0 +1,202 @@
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace EntityFrameworkCore.Projectables.Generator.Tests;
+
+///
+/// Tests that the generator reports EFP0012 (Info) when a [Projectable] method
+/// matches the factory-method pattern (expression body => new ContainingType { … }),
+/// and that it does NOT report the diagnostic for methods that do not match.
+///
+/// Unlike the previous standalone DiagnosticAnalyzer, the diagnostic is now emitted
+/// directly in so that it is part of the
+/// same incremental pipeline that produces the expression tree — no separate analysis pass needed.
+///
+///
+public class FactoryMethodDiagnosticTests : ProjectionExpressionGeneratorTestsBase
+{
+ public FactoryMethodDiagnosticTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { }
+
+ // ────────────────────────────────────────────────────────────────────────
+ // EFP0012 is reported
+ // ────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ReportsEFP0012_OnStaticFactoryMethod()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class OtherObj { }
+ class MyObj {
+ [Projectable]
+ public static MyObj Create(OtherObj o) => new MyObj { };
+ }
+}");
+ var result = RunGenerator(compilation);
+
+ var diag = Assert.Single(result.Diagnostics);
+ Assert.Equal("EFP0012", diag.Id);
+ Assert.Equal(DiagnosticSeverity.Info, diag.Severity);
+ Assert.Equal("Create", diag.Location.SourceTree!
+ .GetRoot().FindToken(diag.Location.SourceSpan.Start).ValueText);
+ }
+
+ [Fact]
+ public void ReportsEFP0012_WithMultipleInitializerAssignments()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class Src { public int A { get; set; } public int B { get; set; } }
+ class Dest {
+ public int A { get; set; }
+ public int B { get; set; }
+ [Projectable]
+ public static Dest Map(Src s) => new Dest { A = s.A, B = s.B };
+ }
+}");
+ var result = RunGenerator(compilation);
+
+ var diag = Assert.Single(result.Diagnostics);
+ Assert.Equal("EFP0012", diag.Id);
+ }
+
+ [Fact]
+ public void ReportsEFP0012_AndStillGeneratesExpressionTree()
+ {
+ // EFP0012 is Info — generation must not be blocked.
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class Input { public int Value { get; set; } }
+ class Output {
+ public int Value { get; set; }
+ [Projectable]
+ public static Output From(Input i) => new Output { Value = i.Value };
+ }
+}");
+ var result = RunGenerator(compilation);
+
+ Assert.Single(result.Diagnostics.Where(d => d.Id == "EFP0012"));
+ Assert.Single(result.GeneratedTrees); // expression tree is still generated
+ }
+
+ [Fact]
+ public void ReportsEFP0012_WithProjectableOptions_Preserved()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class OtherObj { }
+ class MyObj {
+ [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)]
+ public static MyObj Create(OtherObj o) => new MyObj { };
+ }
+}");
+ var result = RunGenerator(compilation);
+
+ var diag = Assert.Single(result.Diagnostics);
+ Assert.Equal("EFP0012", diag.Id);
+ }
+
+ // ────────────────────────────────────────────────────────────────────────
+ // EFP0012 is NOT reported
+ // ────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void NoEFP0012_WhenBodyHasConstructorArguments()
+ {
+ // new MyObj(x) { } — has constructor args, not a pure initializer
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class MyObj {
+ public MyObj() { }
+ public MyObj(int x) { }
+ [Projectable]
+ public static MyObj Create(int x) => new MyObj(x) { };
+ }
+}");
+ var result = RunGenerator(compilation);
+
+ Assert.DoesNotContain(result.Diagnostics, d => d.Id == "EFP0012");
+ }
+
+ [Fact]
+ public void NoEFP0012_WhenBodyHasNoInitializer()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class MyObj {
+ public MyObj() { }
+ [Projectable]
+ public static MyObj Create() => new MyObj();
+ }
+}");
+ var result = RunGenerator(compilation);
+
+ Assert.DoesNotContain(result.Diagnostics, d => d.Id == "EFP0012");
+ }
+
+ [Fact]
+ public void NoEFP0012_WhenReturnTypeDoesNotMatchContainingClass()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class Other { }
+ class MyObj {
+ [Projectable]
+ public static Other Create() => new Other { };
+ }
+}");
+ var result = RunGenerator(compilation);
+
+ Assert.DoesNotContain(result.Diagnostics, d => d.Id == "EFP0012");
+ }
+
+ [Fact]
+ public void NoEFP0012_WhenBodyIsNotObjectCreation()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class MyObj {
+ public int Value { get; set; }
+ [Projectable]
+ public int Computed => Value * 2;
+ }
+}");
+ var result = RunGenerator(compilation);
+
+ Assert.Empty(result.Diagnostics);
+ }
+
+ [Fact]
+ public void NoEFP0012_ForProjectableConstructor()
+ {
+ var compilation = CreateCompilation(@"
+using EntityFrameworkCore.Projectables;
+namespace Foo {
+ class OtherObj { public int X { get; set; } }
+ class MyObj {
+ public int X { get; set; }
+ public MyObj() { }
+ [Projectable]
+ public MyObj(OtherObj o) {
+ X = o.X;
+ }
+ }
+}");
+ var result = RunGenerator(compilation);
+
+ // A [Projectable] constructor is not a factory method — EFP0012 must not be reported.
+ Assert.DoesNotContain(result.Diagnostics, d => d.Id == "EFP0012");
+ }
+}
+
+