diff --git a/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs index 26e7a020d5..f14defe612 100644 --- a/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/SqlQueryStructure.cs @@ -775,14 +775,14 @@ private void AddGraphQLFields(IReadOnlyList selections, RuntimeC .First(); AddGraphQLFields(fragmentDocumentNode.SelectionSet.Selections, runtimeConfigProvider); - return; + continue; } if (node.Kind == SyntaxKind.InlineFragment) { InlineFragmentNode inlineFragment = (InlineFragmentNode)node; AddGraphQLFields(inlineFragment.SelectionSet.Selections, runtimeConfigProvider); - return; + continue; } if (node.Kind != SyntaxKind.Field) diff --git a/src/Service.Tests/UnitTests/SqlQueryStructureHelperTests.cs b/src/Service.Tests/UnitTests/SqlQueryStructureHelperTests.cs index ee35167962..88fdc2412b 100644 --- a/src/Service.Tests/UnitTests/SqlQueryStructureHelperTests.cs +++ b/src/Service.Tests/UnitTests/SqlQueryStructureHelperTests.cs @@ -1,14 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; +using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Core.Models; using Azure.DataApiBuilder.Core.Resolvers; +using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; using HotChocolate.Language; +using HotChocolate.Resolvers; using Microsoft.AspNetCore.Http; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -127,6 +132,77 @@ public void AddGraphQLFields_InlineFragmentSkipsIntrospectionField() new object?[] { new ISelectionNode[] { fragment }, null }); } + /// + /// Selections after an inline fragment must still be processed. Using return after + /// expanding the fragment exits the foreach and drops trailing fields. + /// + [TestMethod] + public void AddGraphQLFields_ContinuesAfterInlineFragment() + { + SqlQueryStructure structure = CreateStructureForColumnSelection(); + InlineFragmentNode fragment = new( + location: null, + typeCondition: new NamedTypeNode("book"), + directives: System.Array.Empty(), + selectionSet: new SelectionSetNode(new ISelectionNode[] { new FieldNode("id") })); + + GetPrivateMethod("AddGraphQLFields").Invoke( + structure, + new object?[] + { + new ISelectionNode[] { fragment, new FieldNode("title") }, + null + }); + + CollectionAssert.AreEquivalent(new[] { "id", "title" }, structure.Columns.Select(c => c.Label).ToList()); + } + + /// + /// Selections after a named fragment spread must still be processed. + /// This matches the #3861 query: ...bookFields (selecting id) followed by title. + /// Using return after expanding the spread exits the foreach and drops the trailing field. + /// + [TestMethod] + public void AddGraphQLFields_ContinuesAfterNamedFragmentSpread() + { + const string query = """ + query { + book_by_pk(id: 1) { + ...bookFields + title + } + } + + fragment bookFields on book { id } + """; + + DocumentNode document = Utf8GraphQLParser.Parse(query); + OperationDefinitionNode operationDefinition = document.Definitions.OfType().Single(); + FieldNode bookByPk = operationDefinition.SelectionSet.Selections.OfType().Single(); + + // Operation.Document is a non-virtual property on a sealed type, so the document + // is assigned the same way other uninitialized query-structure state is in this fixture. + // The name is fully qualified because Azure.Core also defines Azure.Operation. + HotChocolate.Execution.Processing.Operation graphqlOperation = + (HotChocolate.Execution.Processing.Operation)RuntimeHelpers.GetUninitializedObject( + typeof(HotChocolate.Execution.Processing.Operation)); + typeof(HotChocolate.Execution.Processing.Operation) + .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(graphqlOperation, document); + Mock context = new(); + context.SetupGet(ctx => ctx.Operation).Returns(graphqlOperation); + + SqlQueryStructure structure = CreateStructureForColumnSelection(); + typeof(SqlQueryStructure).GetField("_ctx", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(structure, context.Object); + + GetPrivateMethod("AddGraphQLFields").Invoke( + structure, + new object?[] { bookByPk.SelectionSet!.Selections, null }); + + CollectionAssert.AreEquivalent(new[] { "id", "title" }, structure.Columns.Select(c => c.Label).ToList()); + } + [TestMethod] public void AddGraphQLFields_UnsupportedSelectionThrows() { @@ -175,9 +251,30 @@ private static SqlQueryStructure CreateStructure() return structure; } - private static void SetAutoProperty(SqlQueryStructure structure, string propertyName, T value) + private static SqlQueryStructure CreateStructureForColumnSelection() + { + SqlQueryStructure structure = CreateStructure(); + Mock metadata = new(); + metadata + .Setup(m => m.TryGetBackingColumn(It.IsAny(), It.IsAny(), out It.Ref.IsAny)) + .Returns((string _, string field, out string? backing) => + { + backing = field; + return true; + }); + + SetAutoProperty(structure, "MetadataProvider", metadata.Object, typeof(BaseQueryStructure)); + SetAutoProperty(structure, "DatabaseObject", new DatabaseTable("public", "books"), typeof(BaseQueryStructure)); + SetAutoProperty(structure, "Columns", new List(), typeof(BaseQueryStructure)); + structure.EntityName = "book"; + structure.SourceAlias = "table0"; + return structure; + } + + private static void SetAutoProperty(SqlQueryStructure structure, string propertyName, T value, Type? declaringType = null) { - typeof(SqlQueryStructure).GetField($"<{propertyName}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! + Type type = declaringType ?? typeof(SqlQueryStructure); + type.GetField($"<{propertyName}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! .SetValue(structure, value); }