diff --git a/src/HotChocolate/Core/src/Types/Types/Relay/NodeFieldResolvers.cs b/src/HotChocolate/Core/src/Types/Types/Relay/NodeFieldResolvers.cs index c188213f885..3df7feb237f 100644 --- a/src/HotChocolate/Core/src/Types/Types/Relay/NodeFieldResolvers.cs +++ b/src/HotChocolate/Core/src/Types/Types/Relay/NodeFieldResolvers.cs @@ -30,9 +30,11 @@ public static async ValueTask ResolveSingleNodeAsync( if (context.Schema.Types.TryGetType(typeName, out var type) && type.Features.Get() is { NodeResolver: not null } feature) { + var typeConverter = context.Service(); SetLocalContext(context, nodeId, deserializedId, type); TryReplaceArguments(context, feature.NodeResolver, Id, nodeId); await feature.NodeResolver.Pipeline.Invoke(context); + context.Result = CoerceResult(context.Result, type, typeConverter); } else { @@ -74,6 +76,7 @@ public static async ValueTask ResolveManyNodeAsync( var tasks = ArrayPool>.Shared.Rent(list.Items.Count); var results = new object?[list.Items.Count]; var ct = context.RequestAborted; + var typeConverter = context.Service(); for (var i = 0; i < list.Items.Count; i++) { @@ -90,7 +93,7 @@ public static async ValueTask ResolveManyNodeAsync( var nodeContext = context.Clone(); SetLocalContext(nodeContext, nodeId, deserializedId, type); TryReplaceArguments(nodeContext, feature.NodeResolver, Ids, nodeId); - tasks[i] = ExecutePipelineAsync(nodeContext, feature.NodeResolver); + tasks[i] = ExecutePipelineAsync(nodeContext, type, feature.NodeResolver, typeConverter); } else { @@ -158,12 +161,13 @@ public static async ValueTask ResolveManyNodeAsync( if (schema.Types.TryGetType(typeName, out var type) && type.Features.Get() is { NodeResolver: not null } feature) { + var typeConverter = context.Service(); var nodeContext = context.Clone(); SetLocalContext(nodeContext, nodeId, deserializedId, type); TryReplaceArguments(nodeContext, feature.NodeResolver, Ids, nodeId); - var result = await ExecutePipelineAsync(nodeContext, feature.NodeResolver); + var result = await ExecutePipelineAsync(nodeContext, type, feature.NodeResolver, typeConverter); if (result is IError error) { @@ -192,10 +196,12 @@ public static async ValueTask ResolveManyNodeAsync( static async Task ExecutePipelineAsync( IMiddlewareContext nodeResolverContext, - NodeResolverInfo nodeResolverInfo) + ObjectType type, + NodeResolverInfo nodeResolverInfo, + ITypeConverter typeConverter) { await nodeResolverInfo.Pipeline.Invoke(nodeResolverContext).ConfigureAwait(false); - return nodeResolverContext.Result; + return CoerceResult(nodeResolverContext.Result, type, typeConverter); } } @@ -242,6 +248,22 @@ private static void TryReplaceArguments( } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static object? CoerceResult( + object? result, + ObjectType type, + ITypeConverter typeConverter) + { + if (result is null || result is IError || type.RuntimeType.IsInstanceOfType(result)) + { + return result; + } + + return typeConverter.TryConvert(type.RuntimeType, result, out var converted) + ? converted + : result; + } + private static void ReportError(IResolverContext context, int item, Exception ex) => context.ReportError(ex, error => error.SetPath(context.Path.Append(item))); } diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Relay/NodeTypeTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Relay/NodeTypeTests.cs index 99355e2bb64..49add0e01a9 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Relay/NodeTypeTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Relay/NodeTypeTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HotChocolate.Execution; using HotChocolate.Tests; using HotChocolate.Types.Relay; @@ -111,6 +112,55 @@ public async Task Infer_Node_From_Query_Field_Resolve_Node() .MatchSnapshotAsync(); } + [Fact] + public async Task Infer_Node_From_Query_Field_Resolve_Node_With_Runtime_Type_Conversion() + { + var executor = await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddGlobalObjectIdentification() + .AddTypeConverter(wrapper => wrapper.Value) + .BuildRequestExecutorAsync(); + + var serializer = executor.Schema.Services.GetRequiredService(); + var id = serializer.Format("Foo", "abc"); + + var result = await executor.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + """ + query ($id: ID!) { + node(id: $id) { + ... on Foo { + id + clearTextId + } + } + nodes(ids: [$id, $id]) { + ... on Foo { + clearTextId + } + } + } + """) + .SetVariableValues(new Dictionary { { "id", id } }) + .Build()); + + var operationResult = result.ExpectOperationResult(); + + Assert.True( + operationResult.Errors is null || operationResult.Errors.Count == 0, + $"Expected no errors but got: {operationResult.ToJson()}"); + + using var document = JsonDocument.Parse(operationResult.ToJson()); + var data = document.RootElement.GetProperty("data"); + + Assert.Equal(id, data.GetProperty("node").GetProperty("id").GetString()); + Assert.Equal("abc", data.GetProperty("node").GetProperty("clearTextId").GetString()); + Assert.Equal("abc", data.GetProperty("nodes")[0].GetProperty("clearTextId").GetString()); + Assert.Equal("abc", data.GetProperty("nodes")[1].GetProperty("clearTextId").GetString()); + } + [Fact] public async Task Infer_Node_From_Query_Field_With_Abc_Argument_Resolve_Node() { @@ -267,6 +317,18 @@ public class Query2 public Foo GetFooById(string abc) => new(abc); } + public class Query10 + { + [NodeResolver] + public object GetFooById(string id) => new FooWrapper(new Foo(id)); + } + + public class Query10Type : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + => descriptor.Field(t => t.GetFooById(default!)).Type>(); + } + public class Query3 { [NodeResolver] @@ -299,6 +361,11 @@ public class Foo(string id) public string ClearTextId => Id; } + public sealed class FooWrapper(Foo value) + { + public Foo Value { get; } = value; + } + public class Bar(int id) { public int Id { get; } = id;