Skip to content

Commit 36ae271

Browse files
authored
Drop abstract type projection support (#1233)
1 parent d093c60 commit 36ae271

58 files changed

Lines changed: 501 additions & 3801 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/GraphQL.EntityFramework.Analyzers/DiagnosticDescriptors.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ public static class DiagnosticDescriptors
5353
public static readonly DiagnosticDescriptor GQLEF007 = new(
5454
id: "GQLEF007",
5555
title: "Identity projection with abstract navigation access",
56-
messageFormat: "Filter for '{0}' uses identity projection but accesses abstract navigation '{1}'. This forces Include() to load all columns. Use explicit projection to load only required properties.",
56+
messageFormat: "Filter for '{0}' uses identity projection but accesses abstract navigation '{1}'. Abstract types cannot be projected and will throw at runtime. Use explicit projection to extract only required properties.",
5757
category: "Usage",
5858
defaultSeverity: DiagnosticSeverity.Error,
5959
isEnabledByDefault: true,
60-
description: "Abstract type navigations cannot use SQL projections and fall back to Include() which loads all columns. Use explicit projection to extract only required properties from abstract navigations.",
60+
description: "Abstract types cannot be projected with Select() and will throw InvalidOperationException at runtime. Use explicit projection to extract only required properties from abstract navigations.",
6161
helpLinkUri: "https://github.com/SimonCropp/GraphQL.EntityFramework#abstract-navigation-projections");
6262
}

src/GraphQL.EntityFramework.Analyzers/FilterIdentityProjectionAnalyzer.cs

Lines changed: 130 additions & 318 deletions
Large diffs are not rendered by default.
Lines changed: 68 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
using Microsoft.CodeAnalysis.CSharp;
2-
31
namespace GraphQL.EntityFramework.CodeFixes;
42

53
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AbstractNavigationProjectionCodeFixProvider))]
@@ -23,9 +21,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
2321
}
2422

2523
var diagnostic = context.Diagnostics[0];
26-
var diagnosticSpan = diagnostic.Location.SourceSpan;
27-
28-
var node = root.FindNode(diagnosticSpan);
24+
var node = root.FindNode(diagnostic.Location.SourceSpan);
2925
var invocation = node as InvocationExpressionSyntax ?? node.FirstAncestorOrSelf<InvocationExpressionSyntax>();
3026

3127
if (invocation == null)
@@ -73,50 +69,31 @@ static async Task<Document> ConvertToExplicitProjectionAsync(
7369
}
7470
}
7571

76-
// If no explicit projection argument, this is 4-parameter syntax
77-
var is4ParamSyntax = projectionArgumentIndex == -1;
78-
7972
if (filterLambda == null)
8073
{
8174
return document;
8275
}
8376

84-
// Extract accessed properties from filter
85-
var accessedProperties = ExtractAccessedProperties(filterLambda);
86-
if (accessedProperties.Count == 0)
77+
var entityParamName = GetLastParameterName(filterLambda);
78+
if (entityParamName == null)
8779
{
8880
return document;
8981
}
9082

91-
// Get the entity parameter name from the filter (last parameter)
92-
string entityParamName;
93-
if (filterLambda is SimpleLambdaExpressionSyntax simpleLambda)
94-
{
95-
entityParamName = simpleLambda.Parameter.Identifier.Text;
96-
}
97-
else if (filterLambda is ParenthesizedLambdaExpressionSyntax parenthesizedLambda)
98-
{
99-
var paramCount = parenthesizedLambda.ParameterList.Parameters.Count;
100-
entityParamName = paramCount > 0
101-
? parenthesizedLambda.ParameterList.Parameters[paramCount - 1].Identifier.Text
102-
: "entity";
103-
}
104-
else
83+
var accessedProperties = ExtractAccessedProperties(filterLambda.Body, entityParamName);
84+
if (accessedProperties.Count == 0)
10585
{
10686
return document;
10787
}
10888

109-
// Build new projection lambda: e => new { e.Id, Prop1 = e.Nav.Prop1, ... }
11089
var newProjectionLambda = BuildProjectionLambda(entityParamName, accessedProperties);
111-
112-
// Build new filter lambda with renamed parameter: (_, _, _, proj) => proj.Prop1 == value
11390
var newFilterLambda = BuildFilterLambda(filterLambda, accessedProperties);
11491

11592
// Replace arguments
11693
SyntaxNode newInvocation;
117-
if (is4ParamSyntax)
94+
if (projectionArgumentIndex == -1)
11895
{
119-
// Add projection argument
96+
// 4-parameter syntax: add projection argument
12097
var projectionArg = SyntaxFactory.Argument(newProjectionLambda)
12198
.WithNameColon(SyntaxFactory.NameColon("projection"))
12299
.WithLeadingTrivia(SyntaxFactory.Whitespace("\n "));
@@ -131,22 +108,16 @@ static async Task<Document> ConvertToExplicitProjectionAsync(
131108
}
132109
else
133110
{
134-
// Replace projection and filter arguments
111+
// Replace existing projection and filter arguments
135112
var newArguments = arguments;
136113

137-
if (projectionArgumentIndex >= 0)
138-
{
139-
newArguments = newArguments.Replace(
140-
newArguments[projectionArgumentIndex],
141-
newArguments[projectionArgumentIndex].WithExpression(newProjectionLambda));
142-
}
114+
newArguments = newArguments.Replace(
115+
newArguments[projectionArgumentIndex],
116+
newArguments[projectionArgumentIndex].WithExpression(newProjectionLambda));
143117

144-
if (filterArgumentIndex >= 0)
145-
{
146-
newArguments = newArguments.Replace(
147-
newArguments[filterArgumentIndex],
148-
newArguments[filterArgumentIndex].WithExpression(newFilterLambda));
149-
}
118+
newArguments = newArguments.Replace(
119+
newArguments[filterArgumentIndex],
120+
newArguments[filterArgumentIndex].WithExpression(newFilterLambda));
150121

151122
newInvocation = invocation.WithArgumentList(
152123
invocation.ArgumentList.WithArguments(newArguments));
@@ -156,69 +127,47 @@ static async Task<Document> ConvertToExplicitProjectionAsync(
156127
return document.WithSyntaxRoot(newRoot);
157128
}
158129

159-
static List<PropertyAccess> ExtractAccessedProperties(LambdaExpressionSyntax filterLambda)
130+
static string? GetLastParameterName(LambdaExpressionSyntax lambda) =>
131+
lambda switch
132+
{
133+
SimpleLambdaExpressionSyntax simple => simple.Parameter.Identifier.Text,
134+
ParenthesizedLambdaExpressionSyntax { ParameterList.Parameters.Count: > 0 } parenthesized =>
135+
parenthesized.ParameterList.Parameters[parenthesized.ParameterList.Parameters.Count - 1].Identifier.Text,
136+
_ => null
137+
};
138+
139+
static ExpressionSyntax UnwrapNullForgiving(ExpressionSyntax expression) =>
140+
expression is PostfixUnaryExpressionSyntax { RawKind: (int)SyntaxKind.SuppressNullableWarningExpression } postfix
141+
? postfix.Operand
142+
: expression;
143+
144+
static List<PropertyAccess> ExtractAccessedProperties(CSharpSyntaxNode body, string paramName)
160145
{
161146
var properties = new List<PropertyAccess>();
162-
var body = filterLambda.Body;
163147

164-
// Get filter parameter name
165-
string? paramName = null;
166-
if (filterLambda is SimpleLambdaExpressionSyntax simpleLambda)
148+
foreach (var memberAccess in body.DescendantNodesAndSelf().OfType<MemberAccessExpressionSyntax>())
167149
{
168-
paramName = simpleLambda.Parameter.Identifier.Text;
169-
}
170-
else if (filterLambda is ParenthesizedLambdaExpressionSyntax parenthesizedLambda)
171-
{
172-
var paramCount = parenthesizedLambda.ParameterList.Parameters.Count;
173-
if (paramCount > 0)
150+
// Look for: e.Nav.Prop or e.Nav!.Prop
151+
var inner = UnwrapNullForgiving(memberAccess.Expression);
152+
153+
if (inner is not MemberAccessExpressionSyntax nestedAccess)
174154
{
175-
paramName = parenthesizedLambda.ParameterList.Parameters[paramCount - 1].Identifier.Text;
155+
continue;
176156
}
177-
}
178157

179-
if (paramName == null)
180-
{
181-
return properties;
182-
}
183-
184-
// Find all member accesses
185-
var memberAccesses = body.DescendantNodesAndSelf()
186-
.OfType<MemberAccessExpressionSyntax>();
187-
188-
foreach (var memberAccess in memberAccesses)
189-
{
190-
// Look for patterns like: e.Parent.Property or e.Parent!.Property (with null-forgiving operator)
191-
// The null-forgiving operator creates a PostfixUnaryExpressionSyntax wrapper
192-
var innerExpression = memberAccess.Expression;
193-
194-
// Unwrap null-forgiving operator if present: e.Parent! -> e.Parent
195-
if (innerExpression is PostfixUnaryExpressionSyntax { RawKind: (int)SyntaxKind.SuppressNullableWarningExpression } postfix)
158+
var root = UnwrapNullForgiving(nestedAccess.Expression);
159+
if (root is not IdentifierNameSyntax identifier || identifier.Identifier.Text != paramName)
196160
{
197-
innerExpression = postfix.Operand;
161+
continue;
198162
}
199163

200-
if (innerExpression is MemberAccessExpressionSyntax nestedAccess)
164+
var navName = nestedAccess.Name.Identifier.Text;
165+
var propName = memberAccess.Name.Identifier.Text;
166+
var fullPath = $"{navName}.{propName}";
167+
168+
if (!properties.Any(_ => _.FullPath == fullPath))
201169
{
202-
// Check if the root is our parameter (possibly with null-forgiving)
203-
var rootExpr = nestedAccess.Expression;
204-
if (rootExpr is PostfixUnaryExpressionSyntax { RawKind: (int)SyntaxKind.SuppressNullableWarningExpression } rootPostfix)
205-
{
206-
rootExpr = rootPostfix.Operand;
207-
}
208-
209-
if (rootExpr is IdentifierNameSyntax identifier && identifier.Identifier.Text == paramName)
210-
{
211-
// e.Parent.Property - extract as "Parent.Property"
212-
var navName = nestedAccess.Name.Identifier.Text;
213-
var propName = memberAccess.Name.Identifier.Text;
214-
var fullPath = $"{navName}.{propName}";
215-
var flatName = $"{navName}{propName}";
216-
217-
if (!properties.Any(p => p.FullPath == fullPath))
218-
{
219-
properties.Add(new(fullPath, flatName, memberAccess));
220-
}
221-
}
170+
properties.Add(new(fullPath, $"{navName}{propName}", memberAccess));
222171
}
223172
}
224173

@@ -229,26 +178,20 @@ static LambdaExpressionSyntax BuildProjectionLambda(
229178
string paramName,
230179
List<PropertyAccess> properties)
231180
{
232-
// Build: _ => new { _.Id, Prop1 = _.Nav.Prop1, ... }
233-
var parameter = SyntaxFactory.Parameter(SyntaxFactory.Identifier(paramName));
234-
235-
List<AnonymousObjectMemberDeclaratorSyntax> initializers = [];
236-
237-
// Add Id property
238-
initializers.Add(
181+
var initializers = new List<AnonymousObjectMemberDeclaratorSyntax>
182+
{
183+
// Always include Id
239184
SyntaxFactory.AnonymousObjectMemberDeclarator(
240185
SyntaxFactory.MemberAccessExpression(
241186
SyntaxKind.SimpleMemberAccessExpression,
242187
SyntaxFactory.IdentifierName(paramName),
243-
SyntaxFactory.IdentifierName("Id"))));
188+
SyntaxFactory.IdentifierName("Id")))
189+
};
244190

245-
// Add accessed properties with flattened names
246191
foreach (var prop in properties)
247192
{
248-
var parts = prop.FullPath.Split('.');
249193
ExpressionSyntax expression = SyntaxFactory.IdentifierName(paramName);
250-
251-
foreach (var part in parts)
194+
foreach (var part in prop.FullPath.Split('.'))
252195
{
253196
expression = SyntaxFactory.MemberAccessExpression(
254197
SyntaxKind.SimpleMemberAccessExpression,
@@ -262,38 +205,26 @@ static LambdaExpressionSyntax BuildProjectionLambda(
262205
expression));
263206
}
264207

265-
var anonymousObject = SyntaxFactory.AnonymousObjectCreationExpression(
266-
SyntaxFactory.SeparatedList(initializers));
267-
268-
return SyntaxFactory.SimpleLambdaExpression(parameter, anonymousObject);
208+
return SyntaxFactory.SimpleLambdaExpression(
209+
SyntaxFactory.Parameter(SyntaxFactory.Identifier(paramName)),
210+
SyntaxFactory.AnonymousObjectCreationExpression(SyntaxFactory.SeparatedList(initializers)));
269211
}
270212

271213
static LambdaExpressionSyntax BuildFilterLambda(
272214
LambdaExpressionSyntax originalFilter,
273215
List<PropertyAccess> properties)
274216
{
275-
// Replace entity parameter references with proj and update property accesses
276-
var newBody = originalFilter.Body;
277-
278-
// Replace each property access with flattened name
279-
foreach (var prop in properties)
280-
{
281-
// Replace e.Parent.Property with proj.ParentProperty
282-
newBody = newBody.ReplaceNodes(
283-
newBody.DescendantNodesAndSelf().Where(_ => _ == prop.OriginalAccess),
284-
(_, _) => SyntaxFactory.MemberAccessExpression(
217+
// Replace all property accesses in a single pass
218+
var replacements = properties.ToDictionary(
219+
_ => _.OriginalAccess, SyntaxNode (_) =>
220+
SyntaxFactory.MemberAccessExpression(
285221
SyntaxKind.SimpleMemberAccessExpression,
286222
SyntaxFactory.IdentifierName("proj"),
287-
SyntaxFactory.IdentifierName(prop.FlatName)));
288-
}
223+
SyntaxFactory.IdentifierName(_.FlatName)));
289224

290-
// Build new parameter list with "proj" as last parameter
291-
if (originalFilter is SimpleLambdaExpressionSyntax)
292-
{
293-
return SyntaxFactory.SimpleLambdaExpression(
294-
SyntaxFactory.Parameter(SyntaxFactory.Identifier("proj")),
295-
newBody);
296-
}
225+
var newBody = originalFilter.Body.ReplaceNodes(
226+
replacements.Keys,
227+
(original, _) => replacements[original]);
297228

298229
if (originalFilter is ParenthesizedLambdaExpressionSyntax parenthesizedLambda)
299230
{
@@ -306,20 +237,15 @@ static LambdaExpressionSyntax BuildFilterLambda(
306237
newBody);
307238
}
308239

309-
return originalFilter;
240+
return SyntaxFactory.SimpleLambdaExpression(
241+
SyntaxFactory.Parameter(SyntaxFactory.Identifier("proj")),
242+
newBody);
310243
}
311244

312-
class PropertyAccess
245+
class PropertyAccess(string fullPath, string flatName, SyntaxNode originalAccess)
313246
{
314-
public PropertyAccess(string fullPath, string flatName, SyntaxNode originalAccess)
315-
{
316-
FullPath = fullPath;
317-
FlatName = flatName;
318-
OriginalAccess = originalAccess;
319-
}
320-
321-
public string FullPath { get; }
322-
public string FlatName { get; }
323-
public SyntaxNode OriginalAccess { get; }
247+
public string FullPath { get; } = fullPath;
248+
public string FlatName { get; } = flatName;
249+
public SyntaxNode OriginalAccess { get; } = originalAccess;
324250
}
325251
}

src/GraphQL.EntityFramework.CodeFixes/GlobalUsings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
global using Microsoft.CodeAnalysis;
44
global using Microsoft.CodeAnalysis.CodeActions;
55
global using Microsoft.CodeAnalysis.CodeFixes;
6+
global using Microsoft.CodeAnalysis.CSharp;
67
global using Microsoft.CodeAnalysis.CSharp.Syntax;

0 commit comments

Comments
 (0)