From fa73cab8546189dc4733ed7194f4df7f9de6f955 Mon Sep 17 00:00:00 2001 From: Glen Date: Wed, 10 Jun 2026 11:26:31 +0200 Subject: [PATCH] Fix `Optional` argument binding in source generator --- .../FileBuilders/TypeFileBuilderBase.cs | 14 +++++ .../Helpers/SymbolExtensions.cs | 13 +++++ .../src/Types.Analyzers/WellKnownTypes.cs | 2 + .../IntegrationTests.cs | 52 +++++++++++++++++++ .../Types.cs | 5 ++ .../IntegrationTests.Schema_Snapshot.snap | 1 + ...shot_Without_ConnectionName_Inference.snap | 1 + 7 files changed, 88 insertions(+) diff --git a/src/HotChocolate/Core/src/Types.Analyzers/FileBuilders/TypeFileBuilderBase.cs b/src/HotChocolate/Core/src/Types.Analyzers/FileBuilders/TypeFileBuilderBase.cs index 422f4aebe19..83bb42ff5b7 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/FileBuilders/TypeFileBuilderBase.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/FileBuilders/TypeFileBuilderBase.cs @@ -1406,6 +1406,20 @@ private void WriteResolverArguments(Resolver resolver, IMethodSymbol resolverMet continue; } + // Optional arguments bind through ArgumentOptional, which coerces the inner T + // and preserves whether the argument was supplied. This mirrors the reflection-based + // binding in ArgumentParameterExpressionBuilder. + if (parameter.Kind is ResolverParameterKind.Argument or ResolverParameterKind.Unknown + && parameter.Type.IsOptional(out var optionalInnerType)) + { + Writer.WriteIndentedLine( + "var args{0} = context.ArgumentOptional<{1}>(\"{2}\");", + i, + ToFullyQualifiedString(optionalInnerType, resolverMethod, typeLookup), + parameter.Key ?? parameter.Name); + continue; + } + switch (parameter.Kind) { case ResolverParameterKind.Parent: diff --git a/src/HotChocolate/Core/src/Types.Analyzers/Helpers/SymbolExtensions.cs b/src/HotChocolate/Core/src/Types.Analyzers/Helpers/SymbolExtensions.cs index 0c649d1c6c8..1b8519a6e7e 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/Helpers/SymbolExtensions.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/Helpers/SymbolExtensions.cs @@ -783,6 +783,19 @@ public static bool IsPagingArguments(this IParameterSymbol parameter) public static bool IsSelection(this IParameterSymbol parameter) => parameter.Type.ToDisplayString() == WellKnownTypes.ISelection; + public static bool IsOptional(this ITypeSymbol type, [NotNullWhen(true)] out ITypeSymbol? innerType) + { + if (type is INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: 1 } namedType + && namedType.ToDisplayString().StartsWith(WellKnownTypes.OptionalGeneric, StringComparison.Ordinal)) + { + innerType = namedType.TypeArguments[0]; + return true; + } + + innerType = null; + return false; + } + public static bool IsIsSelected(this IParameterSymbol parameter) { foreach (var attribute in parameter.GetAttributes()) diff --git a/src/HotChocolate/Core/src/Types.Analyzers/WellKnownTypes.cs b/src/HotChocolate/Core/src/Types.Analyzers/WellKnownTypes.cs index cabce26320e..3c5ba66e6a6 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/WellKnownTypes.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/WellKnownTypes.cs @@ -33,6 +33,8 @@ public static class WellKnownTypes public const string FieldResolverDelegate = "HotChocolate.Resolvers.FieldResolverDelegate"; public const string ResolverContext = "HotChocolate.Resolvers.IResolverContext"; public const string ParameterBinding = "HotChocolate.Internal.IParameterBinding"; + public const string Optional = "HotChocolate.Optional"; + public const string OptionalGeneric = Optional + "<"; public const string MemoryMarshal = "System.Runtime.InteropServices.MemoryMarshal"; public const string Unsafe = "System.Runtime.CompilerServices.Unsafe"; public const string Object = "System.Object"; diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/IntegrationTests.cs b/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/IntegrationTests.cs index 52f764592b3..1cb2a3af6a8 100644 --- a/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/IntegrationTests.cs +++ b/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/IntegrationTests.cs @@ -197,4 +197,56 @@ public async Task Resolves_Instance_Method_On_NonStatic_QueryType() Assert.DoesNotContain("\"errors\"", json); Assert.Matches("\"title\": \"[0-9a-f]{32}\"", json); } + + [Fact] + public async Task Resolves_Optional_Argument_That_Has_A_Value() + { + // arrange + var executor = await new ServiceCollection() + .AddGraphQLServer() + .AddIntegrationTestTypes() + .AddPagingArguments() + .BuildRequestExecutorAsync(cancellationToken: TestContext.Current.CancellationToken); + + // act + var result = await executor.ExecuteAsync( + """mutation { setOptionalValue(value: "abc") }""", + TestContext.Current.CancellationToken); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "setOptionalValue": "abc" + } + } + """); + } + + [Fact] + public async Task Resolves_Optional_Argument_That_Has_No_Value() + { + // arrange + var executor = await new ServiceCollection() + .AddGraphQLServer() + .AddIntegrationTestTypes() + .AddPagingArguments() + .BuildRequestExecutorAsync(cancellationToken: TestContext.Current.CancellationToken); + + // act + var result = await executor.ExecuteAsync( + "mutation { setOptionalValue }", + TestContext.Current.CancellationToken); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "setOptionalValue": "unset" + } + } + """); + } } diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/Types.cs b/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/Types.cs index d068bbe2380..d775f6fd4d8 100644 --- a/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/Types.cs +++ b/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/Types.cs @@ -128,6 +128,11 @@ public class Shape public static partial class Mutation { public static bool SaveShapes(IReadOnlyList shapes) => true; + + // An Optional parameter is bound through the runtime parameter binding, + // which must coerce the inner type and honor whether the argument was provided. + public static string SetOptionalValue(Optional value) + => value.HasValue ? value.Value ?? "null" : "unset"; } public class IsSelectedNode diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/__snapshots__/IntegrationTests.Schema_Snapshot.snap b/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/__snapshots__/IntegrationTests.Schema_Snapshot.snap index c8550c6568a..d7146718e61 100644 --- a/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/__snapshots__/IntegrationTests.Schema_Snapshot.snap +++ b/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/__snapshots__/IntegrationTests.Schema_Snapshot.snap @@ -153,6 +153,7 @@ type Query { type Mutation { saveShapes(shapes: [ShapeInput!]!): Boolean! + setOptionalValue(value: String): String! } type Subscription { diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/__snapshots__/IntegrationTests.Schema_Snapshot_Without_ConnectionName_Inference.snap b/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/__snapshots__/IntegrationTests.Schema_Snapshot_Without_ConnectionName_Inference.snap index 080b47f2d68..477a35b15b9 100644 --- a/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/__snapshots__/IntegrationTests.Schema_Snapshot_Without_ConnectionName_Inference.snap +++ b/src/HotChocolate/Core/test/Types.Analyzers.Integration.Tests/__snapshots__/IntegrationTests.Schema_Snapshot_Without_ConnectionName_Inference.snap @@ -104,6 +104,7 @@ type Query { type Mutation { saveShapes(shapes: [ShapeInput!]!): Boolean! + setOptionalValue(value: String): String! } type Subscription {