From 7fd1ea02795795e60517cba39dac6d9a7e39cc71 Mon Sep 17 00:00:00 2001 From: Hans Krogh Thomsen Date: Sun, 5 Oct 2025 16:03:28 +0200 Subject: [PATCH] fix: Add support list value type in requriements --- .../Execution/Results/SelectionMapExecutor.cs | 118 +++++- .../Fusion.AspNetCore.Tests/RequireTests.cs | 107 +++++- ...quireTests.Require_Enumerable_In_List.yaml | 345 ++++++++++++++++++ ...RequireTests.Require_Object_In_A_List.yaml | 20 + 4 files changed, 580 insertions(+), 10 deletions(-) create mode 100644 src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Enumerable_In_List.yaml diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/SelectionMapExecutor.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/SelectionMapExecutor.cs index 24332188715..2036e6a6553 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/SelectionMapExecutor.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/SelectionMapExecutor.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HotChocolate.Buffers; using HotChocolate.Fusion.Language; using HotChocolate.Language; @@ -67,8 +68,6 @@ internal static class ResultDataMapper return null; } - // Note: to capture data from the introspection - // system we would need to also cover raw field results. if (result is LeafFieldResult field) { if (field.HasNullValue) @@ -76,9 +75,17 @@ internal static class ResultDataMapper return NullValueNode.Default; } - context.Writer ??= new PooledArrayWriter(); - var parser = new JsonValueParser(buffer: context.Writer); - return parser.Parse(field.Value); + return MapLeaf(field.Value, ref context.Writer); + } + + if (result is ListFieldResult listField) + { + if (listField.HasNullValue || listField.Value is null) + { + return NullValueNode.Default; + } + + return MapListResult(listField.Value, ref context.Writer); } throw new InvalidSelectionMapPathException(node); @@ -279,6 +286,107 @@ internal static class ResultDataMapper return currentResult; } + private static IValueNode MapListResult(ListResult list, ref PooledArrayWriter? writer) + { + switch (list) + { + case LeafListResult leafList: + { + var items = new List(leafList.Items.Count); + foreach (var json in leafList.Items) + { + if (json.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + items.Add(NullValueNode.Default); + } + else + { + items.Add(MapLeaf(json, ref writer)); + } + } + + return new ListValueNode(items); + } + case ObjectListResult objectList: + { + var items = new List(objectList.Items.Count); + foreach (var obj in objectList.Items) + { + items.Add(obj is null ? NullValueNode.Default : MapObjectResult(obj, ref writer)); + } + + return new ListValueNode(items); + } + case NestedListResult nestedList: + { + var items = new List(nestedList.Items.Count); + foreach (var inner in nestedList.Items) + { + items.Add(inner is null ? NullValueNode.Default : MapListResult(inner, ref writer)); + } + + return new ListValueNode(items); + } + case RawListFieldResult raw: + { + // fallback to JSON serialization for raw encoded list; depth impact is minimal (flat list) + using var temp = new PooledArrayWriter(); + using (var jsonWriter = new Utf8JsonWriter(temp)) + { + raw.WriteTo(jsonWriter); + } + + writer ??= new PooledArrayWriter(); + var parser = new JsonValueParser(buffer: writer); + return parser.Parse(temp.WrittenSpan); + } + default: + throw new NotSupportedException($"Unsupported list result type {list.GetType().Name}."); + } + } + + private static IValueNode MapObjectResult(ObjectResult obj, ref PooledArrayWriter? writer) + { + var fields = obj.Fields; + var list = new List(fields.Length); + for (var i = 0; i < fields.Length; i++) + { + var field = fields[i]; + if (field.Selection.IsInternal) + { + continue; + } + + var valueNode = MapFieldResult(field, ref writer); + list.Add(new ObjectFieldNode(field.Selection.ResponseName, valueNode)); + } + + return new ObjectValueNode(list); + } + + private static IValueNode MapFieldResult(FieldResult field, ref PooledArrayWriter? writer) + { + if (field.HasNullValue) + { + return NullValueNode.Default; + } + + return field switch + { + LeafFieldResult leaf => MapLeaf(leaf.Value, ref writer), + ObjectFieldResult { Value: { } obj } => MapObjectResult(obj, ref writer), + ListFieldResult { Value: { } list } => MapListResult(list, ref writer), + _ => NullValueNode.Default + }; + } + + private static IValueNode MapLeaf(JsonElement element, ref PooledArrayWriter? writer) + { + writer ??= new PooledArrayWriter(); + var parser = new JsonValueParser(buffer: writer); + return parser.Parse(element); + } + private readonly ref struct Context { private readonly ref PooledArrayWriter? _writer; diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/RequireTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/RequireTests.cs index 1e98e39fe45..cd1657159b5 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/RequireTests.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/RequireTests.cs @@ -24,11 +24,17 @@ public async Task Require_Object_In_A_List() "c", b => b.AddQueryType()); + using var server4 = CreateSourceSchema( + "d", + b => b.AddQueryType() + .AddType()); + using var gateway = await CreateCompositeSchemaAsync( [ ("a", server1), ("b", server2), - ("c", server3) + ("c", server3), + ("d", server4) ]); // act @@ -54,6 +60,59 @@ public async Task Require_Object_In_A_List() await MatchSnapshotAsync(gateway, request, result); } + [Fact] + public async Task Require_Enumerable_In_List() + { + // arrange + using var server1 = CreateSourceSchema( + "a", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "b", + b => b.AddQueryType()); + + using var server3 = CreateSourceSchema( + "c", + b => b.AddQueryType()); + using var server4 = CreateSourceSchema( + "d", + b => b.AddQueryType() + .AddType()); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("a", server1), + ("b", server2), + ("c", server3), + ("d", server4) + ]); + + // act + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + books { + nodes { + title + genres { + name + } + } + } + } + """); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + [Fact(Skip = "Not yet supported")] public async Task Require_On_MutationPayload() { @@ -132,9 +191,11 @@ public static class BookCatalog { private static readonly Dictionary s_books = new() { - { 1, new Book { Id = 1, Title = "The Great Gatsby", Author = new Author { Id = 1 } } }, - { 2, new Book { Id = 2, Title = "1984", Author = new Author { Id = 2 } } }, - { 3, new Book { Id = 3, Title = "The Catcher in the Rye", Author = new Author { Id = 3 } } } + { + 1, new Book { Id = 1, Title = "The Great Gatsby", Author = new Author { Id = 1 }, GenreIds = [1, 3] } + }, + { 2, new Book { Id = 2, Title = "1984", Author = new Author { Id = 2 }, GenreIds = [2, 3] } }, + { 3, new Book { Id = 3, Title = "The Catcher in the Rye", Author = new Author { Id = 3 }, GenreIds = [1] } } }; public class Query @@ -155,6 +216,8 @@ public class Book public required string Title { get; set; } public required Author Author { get; set; } + + public required IEnumerable GenreIds { get; set; } } public class Author @@ -194,6 +257,40 @@ public class BookDimension } } + public static class BookGenre + { + private static readonly Dictionary s_books = new() + { + { 1, new Genre { Id = 1, Name = "Fiction" } }, + { 2, new Genre { Id = 2, Name = "Science Fiction" } }, + { 3, new Genre { Id = 3, Name = "Classic" } } + }; + + public class Query + { + [Lookup] + public Book? GetBook(int id) + => new() { Id = id }; + } + + public class Genre + { + public required int Id { get; set; } + public required string Name { get; set; } + } + + public class Book + { + public int Id { get; set; } + + public IEnumerable Genres( + [Require("genreIds")] IEnumerable genreIds) + { + return genreIds.Select(id => s_books[id]); + } + } + } + public static class BookShipping { public class Query @@ -216,7 +313,7 @@ public int EstimatedDelivery( height: dimension.height } """)] - BookDimensionInput dimension) + BookDimensionInput dimension) { return dimension.Width + dimension.Height; } diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Enumerable_In_List.yaml b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Enumerable_In_List.yaml new file mode 100644 index 00000000000..401cd206cf9 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Enumerable_In_List.yaml @@ -0,0 +1,345 @@ +title: Require_Enumerable_In_List +request: + document: | + { + books { + nodes { + title + genres { + name + } + } + } + } +response: + body: | + { + "data": { + "books": { + "nodes": [ + { + "title": "The Great Gatsby", + "genres": [ + { + "name": "Fiction" + }, + { + "name": "Classic" + } + ] + }, + { + "title": "1984", + "genres": [ + { + "name": "Science Fiction" + }, + { + "name": "Classic" + } + ] + }, + { + "title": "The Catcher in the Rye", + "genres": [ + { + "name": "Fiction" + } + ] + } + ] + } + } + } +sourceSchemas: + - name: a + schema: | + schema { + query: Query + } + + type Author { + id: Int! + } + + type Book { + id: Int! + title: String! + author: Author! + genreIds: [Int!]! + } + + "A connection to a list of items." + type BooksConnection { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges." + edges: [BooksEdge!] + "A flattened list of the nodes." + nodes: [Book!] + } + + "An edge in a connection." + type BooksEdge { + "A cursor for use in pagination." + cursor: String! + "The item at the end of the edge." + node: Book! + } + + "Information about pagination in a connection." + type PageInfo { + "Indicates whether more edges exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more edges exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String + "When paginating forwards, the cursor to continue." + endCursor: String + } + + type Query { + books("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): BooksConnection + book(id: Int!): Book @lookup + } + interactions: + - request: + document: | + query Op_684e067a_1 { + books { + nodes { + title + id + genreIds + } + } + } + response: + results: + - | + { + "data": { + "books": { + "nodes": [ + { + "title": "The Great Gatsby", + "id": 1, + "genreIds": [ + 1, + 3 + ] + }, + { + "title": "1984", + "id": 2, + "genreIds": [ + 2, + 3 + ] + }, + { + "title": "The Catcher in the Rye", + "id": 3, + "genreIds": [ + 1 + ] + } + ] + } + } + } + - name: b + schema: | + schema { + query: Query + } + + type Book { + id: Int! + dimension: BookDimension! + } + + type BookDimension { + width: Int! + height: Int! + } + + type Query { + book(id: Int!): Book @lookup + } + - name: c + schema: | + schema { + query: Query + } + + type Book { + estimatedDelivery(dimension: BookDimensionInput! @require(field: "{\n title\n width: dimension.width\n height: dimension.height\n}")): Int! + id: Int! + } + + type Query { + book(id: Int!): Book @lookup + } + + input BookDimensionInput { + title: String! + width: Int! + height: Int! + } + - name: d + schema: | + schema { + query: Query + } + + type Book { + genres(genreIds: [Int!]! @require(field: "genreIds")): [Genre!]! + id: Int! + } + + type Genre { + id: Int! + name: String! + } + + type Query { + book(id: Int!): Book @lookup + } + interactions: + - request: + document: | + query Op_684e067a_2( + $__fusion_1_id: Int! + $__fusion_2_genreIds: [Int!]! + ) { + book(id: $__fusion_1_id) { + genres(genreIds: $__fusion_2_genreIds) { + name + } + } + } + variables: | + [ + { + "__fusion_1_id": 1, + "__fusion_2_genreIds": [ + 1, + 3 + ] + }, + { + "__fusion_1_id": 2, + "__fusion_2_genreIds": [ + 2, + 3 + ] + }, + { + "__fusion_1_id": 3, + "__fusion_2_genreIds": [ + 1 + ] + } + ] + response: + results: + - | + { + "data": { + "book": { + "genres": [ + { + "name": "Fiction" + }, + { + "name": "Classic" + } + ] + } + } + } + - | + { + "data": { + "book": { + "genres": [ + { + "name": "Science Fiction" + }, + { + "name": "Classic" + } + ] + } + } + } + - | + { + "data": { + "book": { + "genres": [ + { + "name": "Fiction" + } + ] + } + } + } +operationPlan: + operation: + - document: | + { + books { + nodes { + title + genres { + name + } + id @fusion__requirement + genreIds @fusion__requirement + } + } + } + hash: 684e067a80efad0f197393d61f996250 + searchSpace: 1 + nodes: + - id: 1 + type: Operation + schema: a + operation: | + query Op_684e067a_1 { + books { + nodes { + title + id + genreIds + } + } + } + - id: 2 + type: Operation + schema: d + operation: | + query Op_684e067a_2( + $__fusion_1_id: Int! + $__fusion_2_genreIds: [Int!]! + ) { + book(id: $__fusion_1_id) { + genres(genreIds: $__fusion_2_genreIds) { + name + } + } + } + source: $.book + target: $.books.nodes + requirements: + - name: __fusion_1_id + selectionMap: >- + id + - name: __fusion_2_genreIds + selectionMap: >- + genreIds + dependencies: + - id: 1 diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Object_In_A_List.yaml b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Object_In_A_List.yaml index ac2b9af6c2a..1d0f0966235 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Object_In_A_List.yaml +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/RequireTests.Require_Object_In_A_List.yaml @@ -46,6 +46,7 @@ sourceSchemas: id: Int! title: String! author: Author! + genreIds: [Int!]! } "A connection to a list of items." @@ -279,6 +280,25 @@ sourceSchemas: } } } + - name: d + schema: | + schema { + query: Query + } + + type Book { + genres(genreIds: [Int!]! @require(field: "genreIds")): [Genre!]! + id: Int! + } + + type Genre { + id: Int! + name: String! + } + + type Query { + book(id: Int!): Book @lookup + } operationPlan: operation: - document: |