Skip to content

Commit f0d02df

Browse files
authored
[Analyzer] Source-gen non-static partial root type classes (#9740)
1 parent a14b67a commit f0d02df

20 files changed

Lines changed: 972 additions & 37 deletions

src/HotChocolate/Core/src/Types.Analyzers/Errors.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public static class Errors
6363
new(
6464
id: "HC0091",
6565
title: "Partial Keyword Missing",
66-
messageFormat: "A static root type class should be declared as partial to allow source generation",
66+
messageFormat: "A root type class should be declared as partial to allow source generation",
6767
category: "TypeSystem",
6868
DiagnosticSeverity.Info,
6969
isEnabledByDefault: true);

src/HotChocolate/Core/src/Types.Analyzers/FileBuilders/RootTypeFileBuilder.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@ public sealed class RootTypeFileBuilder(StringBuilder sb) : TypeFileBuilderBase(
88
{
99
protected override string OutputFieldDescriptorType => WellKnownTypes.ObjectFieldDescriptor;
1010

11+
public override void WriteBeginClass(IOutputTypeInfo type)
12+
{
13+
if (type is RootTypeInfo { IsStatic: false } rootType)
14+
{
15+
Writer.WriteIndentedLine(
16+
"{0} partial class {1}",
17+
rootType.IsPublic ? "public" : "internal",
18+
rootType.Name);
19+
Writer.WriteIndentedLine("{");
20+
Writer.IncreaseIndent();
21+
return;
22+
}
23+
24+
base.WriteBeginClass(type);
25+
}
26+
27+
protected override string GetInstanceReceiver(
28+
string fullyQualifiedTypeName,
29+
string contextExpression = "context")
30+
=> $"{contextExpression}.Resolver<{fullyQualifiedTypeName}>()";
31+
1132
public override void WriteInitializeMethod(IOutputTypeInfo type, ILocalTypeLookup typeLookup)
1233
{
1334
if (type is not RootTypeInfo rootType)

src/HotChocolate/Core/src/Types.Analyzers/FileBuilders/TypeFileBuilderBase.cs

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ public virtual void WriteBeginClass(IOutputTypeInfo type)
4848
Writer.IncreaseIndent();
4949
}
5050

51+
/// <summary>
52+
/// Builds the C# expression that yields the receiver for an instance resolver call.
53+
/// </summary>
54+
/// <param name="fullyQualifiedTypeName">
55+
/// The fully qualified type name of the resolver class (already prefixed with <c>global::</c>).
56+
/// </param>
57+
/// <param name="contextExpression">
58+
/// The C# expression that yields the resolver context (e.g. <c>"context"</c> for
59+
/// single resolvers, <c>"contexts[0]"</c> for batch resolvers).
60+
/// </param>
61+
protected virtual string GetInstanceReceiver(
62+
string fullyQualifiedTypeName,
63+
string contextExpression = "context")
64+
=> $"{contextExpression}.Parent<{fullyQualifiedTypeName}>()";
65+
5166
public void WriteEndClass()
5267
{
5368
Writer.DecreaseIndent();
@@ -920,13 +935,14 @@ private void WriteResolver(
920935
{
921936
WriteResolverArguments(resolver, resolverMethod, typeLookup);
922937

938+
var typeName = resolver.Member.ContainingType.ToFullyQualified();
939+
var receiver = resolver.IsStatic ? typeName : GetInstanceReceiver(typeName);
940+
923941
if (async)
924942
{
925943
Writer.WriteIndentedLine(
926-
resolver.IsStatic
927-
? "var result = await {0}.{1}({2});"
928-
: "var result = await context.Parent<{0}>().{1}({2});",
929-
resolver.Member.ContainingType.ToFullyQualified(),
944+
"var result = await {0}.{1}({2});",
945+
receiver,
930946
resolver.Member.Name,
931947
GetResolverArgumentAssignments(resolver.Parameters.Length));
932948

@@ -935,10 +951,8 @@ private void WriteResolver(
935951
else
936952
{
937953
Writer.WriteIndentedLine(
938-
resolver.IsStatic
939-
? "var result = {0}.{1}({2});"
940-
: "var result = context.Parent<{0}>().{1}({2});",
941-
resolver.Member.ContainingType.ToFullyQualified(),
954+
"var result = {0}.{1}({2});",
955+
receiver,
942956
resolver.Member.Name,
943957
GetResolverArgumentAssignments(resolver.Parameters.Length));
944958

@@ -1022,11 +1036,12 @@ private void WritePureResolver(Resolver resolver, IMethodSymbol resolverMethod,
10221036
{
10231037
WriteResolverArguments(resolver, resolverMethod, typeLookup);
10241038

1039+
var typeName = resolver.Member.ContainingType.ToFullyQualified();
1040+
var receiver = resolver.IsStatic ? typeName : GetInstanceReceiver(typeName);
1041+
10251042
Writer.WriteIndentedLine(
1026-
resolver.IsStatic
1027-
? "var result = {0}.{1}({2});"
1028-
: "var result = context.Parent<{0}>().{1}({2});",
1029-
resolver.Member.ContainingType.ToFullyQualified(),
1043+
"var result = {0}.{1}({2});",
1044+
receiver,
10301045
resolver.Member.Name,
10311046
GetResolverArgumentAssignments(resolver.Parameters.Length));
10321047

@@ -1253,12 +1268,17 @@ or ResolverParameterKind.Argument
12531268

12541269
Writer.WriteLine();
12551270

1256-
// Call the user's batch resolver method
1271+
// Call the user's batch resolver method.
1272+
var batchTypeName = resolver.Member.ContainingType.ToFullyQualified();
1273+
var batchReceiver = resolver.IsStatic
1274+
? batchTypeName
1275+
: GetInstanceReceiver(batchTypeName, "contexts[0]");
1276+
12571277
if (isAsync)
12581278
{
12591279
Writer.WriteIndentedLine(
12601280
"var result = await {0}.{1}({2});",
1261-
resolver.Member.ContainingType.ToFullyQualified(),
1281+
batchReceiver,
12621282
resolver.Member.Name,
12631283
GetResolverArgumentAssignments(resolver.Parameters.Length));
12641284

@@ -1283,7 +1303,7 @@ or ResolverParameterKind.Argument
12831303
{
12841304
Writer.WriteIndentedLine(
12851305
"var result = {0}.{1}({2});",
1286-
resolver.Member.ContainingType.ToFullyQualified(),
1306+
batchReceiver,
12871307
resolver.Member.Name,
12881308
GetResolverArgumentAssignments(resolver.Parameters.Length));
12891309

@@ -1347,11 +1367,12 @@ private void WritePropertyResolver(Resolver resolver)
13471367
Writer.WriteIndentedLine("{");
13481368
using (Writer.IncreaseIndent())
13491369
{
1370+
var typeName = resolver.Member.ContainingType.ToFullyQualified();
1371+
var receiver = resolver.IsStatic ? typeName : GetInstanceReceiver(typeName);
1372+
13501373
Writer.WriteIndentedLine(
1351-
resolver.IsStatic
1352-
? "var result = {0}.{1};"
1353-
: "var result = context.Parent<{0}>().{1};",
1354-
resolver.Member.ContainingType.ToFullyQualified(),
1374+
"var result = {0}.{1};",
1375+
receiver,
13551376
resolver.Member.Name);
13561377

13571378
Writer.WriteIndentedLine("return result;");

src/HotChocolate/Core/src/Types.Analyzers/Inspectors/ObjectTypeInspector.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ public bool TryHandle(GeneratorSyntaxContext context, [NotNullWhen(true)] out Sy
4444
Location.Create(possibleType.SyntaxTree, possibleType.Span)));
4545
}
4646

47-
if (!possibleType.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword)))
47+
// Root types may be instance classes; the static requirement applies only to object type extensions.
48+
if (!isOperationType
49+
&& !possibleType.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword)))
4850
{
4951
diagnostics = diagnostics.Add(
5052
Diagnostic.Create(

src/HotChocolate/Core/src/Types.Analyzers/Inspectors/TypeAttributeInspector.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public bool TryHandle(
6060
{
6161
if (fullName.Equals(QueryTypeAttribute))
6262
{
63-
if (type.IsStatic && possibleType.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
63+
if (possibleType.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
6464
{
6565
syntaxInfo = null;
6666
return false;
@@ -75,7 +75,7 @@ public bool TryHandle(
7575

7676
if (fullName.Equals(MutationTypeAttribute))
7777
{
78-
if (type.IsStatic && possibleType.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
78+
if (possibleType.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
7979
{
8080
syntaxInfo = null;
8181
return false;
@@ -90,7 +90,7 @@ public bool TryHandle(
9090

9191
if (fullName.Equals(SubscriptionTypeAttribute))
9292
{
93-
if (type.IsStatic && possibleType.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
93+
if (possibleType.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
9494
{
9595
syntaxInfo = null;
9696
return false;

src/HotChocolate/Core/src/Types.Analyzers/Models/RootTypeInfo.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections.Immutable;
22
using HotChocolate.Types.Analyzers.Helpers;
33
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
45
using Microsoft.CodeAnalysis.CSharp.Syntax;
56

67
namespace HotChocolate.Types.Analyzers.Models;
@@ -23,6 +24,7 @@ public RootTypeInfo(
2324
RegistrationKey = schemaType.ToAssemblyQualified();
2425
Namespace = schemaType.ContainingNamespace.ToDisplayString();
2526
IsPublic = schemaType.DeclaredAccessibility == Accessibility.Public;
27+
IsStatic = classDeclarationSyntax.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword));
2628
ClassDeclaration = classDeclarationSyntax;
2729
Resolvers = resolvers;
2830
Description = compilation.GetDescription(schemaType);
@@ -45,6 +47,8 @@ public RootTypeInfo(
4547

4648
public bool IsPublic { get; }
4749

50+
public bool IsStatic { get; }
51+
4852
public OperationType OperationType { get; }
4953

5054
public bool HasSchemaType => true;

src/HotChocolate/Core/src/Types.Analyzers/RootTypePartialAnalyzer.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,6 @@ private static void AnalyzeClassDeclaration(SyntaxNodeAnalysisContext context)
2727
return;
2828
}
2929

30-
// Check if class is static
31-
if (!classDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword)))
32-
{
33-
return;
34-
}
35-
3630
// Check if class is already partial
3731
if (classDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
3832
{

src/HotChocolate/Core/src/Types.Analyzers/RootTypePartialCodeFixProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public sealed class RootTypePartialCodeFixProvider : CodeFixProvider
1414
{
1515
private const string Title = "Add partial keyword";
1616

17-
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ["HC0089"];
17+
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ["HC0091"];
1818

1919
public override FixAllProvider GetFixAllProvider()
2020
=> WellKnownFixAllProviders.BatchFixer;

src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/IntegrationTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,25 @@ public async Task Maps_NullOrdering_From_PagingOptions_To_PagingArguments()
111111
Assert.Empty(operationResult.Errors);
112112
Assert.Equal(NullOrdering.NativeNullsFirst, Query.PagingArguments.NullOrdering);
113113
}
114+
115+
[Fact]
116+
public async Task Resolves_Instance_Method_On_NonStatic_QueryType()
117+
{
118+
// arrange
119+
// NonStaticPagedQuery.SomeBooks returns a Book whose title is the resolver
120+
// instance's InstanceId (a 32-char hex GUID).
121+
var executor = await new ServiceCollection()
122+
.AddGraphQLServer()
123+
.AddIntegrationTestTypes()
124+
.AddPagingArguments()
125+
.BuildRequestExecutorAsync();
126+
127+
// act
128+
var result = await executor.ExecuteAsync("{ someBooks { nodes { title } } }");
129+
130+
// assert
131+
var json = result.ToJson();
132+
Assert.DoesNotContain("\"errors\"", json);
133+
Assert.Matches("\"title\": \"[0-9a-f]{32}\"", json);
134+
}
114135
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
namespace HotChocolate.Types;
2+
3+
public class Author
4+
{
5+
public required string Id { get; set; }
6+
public required string Name { get; set; }
7+
}
8+
9+
[ObjectType<Author>]
10+
public static partial class AuthorType
11+
{
12+
[UsePaging]
13+
public static IQueryable<Book> Books([Parent(nameof(Author.Id))] Author parent)
14+
=> Enumerable.Empty<Book>().AsQueryable();
15+
}
16+
17+
public class Publisher
18+
{
19+
public required string Id { get; set; }
20+
public required string Name { get; set; }
21+
}
22+
23+
[ObjectType<Publisher>]
24+
public static partial class PublisherType
25+
{
26+
[UsePaging]
27+
public static IQueryable<Book> Books([Parent(nameof(Publisher.Id))] Publisher parent)
28+
=> Enumerable.Empty<Book>().AsQueryable();
29+
}
30+
31+
[QueryType]
32+
public partial class NonStaticPagedQuery
33+
{
34+
// Per-instance identifier surfaced through SomeBooks so a test can confirm
35+
// the resolver method was invoked on a real instance.
36+
[GraphQLIgnore]
37+
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
38+
39+
[UsePaging]
40+
public IQueryable<Book> SomeBooks() =>
41+
new[] { new Book { Id = "1", Title = InstanceId } }.AsQueryable();
42+
43+
[UsePaging]
44+
#pragma warning disable CA1822 // Mark members as static
45+
public IQueryable<Author> Authors() => Enumerable.Empty<Author>().AsQueryable();
46+
47+
[UsePaging]
48+
public IQueryable<Publisher> Publishers() => Enumerable.Empty<Publisher>().AsQueryable();
49+
#pragma warning restore CA1822 // Mark members as static
50+
}

0 commit comments

Comments
 (0)