From f42fe7ce431f13f111c6207e88094011eaf7e5c8 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 7 Mar 2023 12:09:06 +0100 Subject: [PATCH] Add by key batching to fusion. (#5934) --- .../AspNetCoreResources.Designer.cs | 419 ++++++------------ ...tChocolate.Transport.Sockets.Client.csproj | 1 + .../OperationRequest.cs | 22 + .../Protocols/DataMessageObserver.cs | 2 +- .../GraphQLOverWebSocketProtocolHandler.cs | 8 +- .../GraphQLOverWebSocket/MessageHelper.cs | 3 +- .../Messages/CompleteMessage.cs | 2 +- .../Messages/ErrorMessage.cs | 4 +- .../Messages/NextMessage.cs | 6 +- .../Protocols/MessageStream.cs | 6 +- .../Transport.Sockets.Client/SocketClient.cs | 4 +- .../Execution/Processing/OperationCompiler.cs | 31 +- .../src/Execution/Processing/Selection.cs | 19 +- .../src/Execution/Processing/SelectionSet.cs | 15 + .../Execution/Processing/SelectionVariants.cs | 44 +- .../Fusion/HotChocolate.Fusion.sln | 7 + .../FusionDirectiveArgumentNames.cs | 11 +- .../src/Abstractions/FusionEnumValueNames.cs | 22 + .../src/Abstractions/FusionTypeBaseNames.cs | 10 + .../src/Abstractions/FusionTypeNames.cs | 24 +- .../Composition/Entities/EntityResolver.cs | 42 +- .../Entities/EntityResolverKind.cs | 8 + .../Fusion/src/Composition/FusionTypes.cs | 108 ++++- .../ApplyRenameDirectiveMiddleware.cs | 2 +- .../Enrichers/RefResolverEntityEnricher.cs | 100 ++++- .../Pipeline/MergeEntityMiddleware.cs | 70 +-- .../Pipeline/MergeQueryTypeMiddleware.cs | 27 +- .../Pipeline/RemoveFusionTypesMiddleware.cs | 2 + .../src/Core/Clients/GraphQLClientFactory.cs | 10 +- .../Fusion/src/Core/Clients/GraphQLRequest.cs | 2 +- .../src/Core/Clients/GraphQLResponse.cs | 46 +- ...phQLHttpClient.cs => HttpGraphQLClient.cs} | 78 ++-- .../Fusion/src/Core/Clients/IGraphQLClient.cs | 28 +- .../Clients/IGraphQLSubscriptionClient.cs | 28 ++ .../Clients/InvalidContentTypeException.cs | 8 + .../WebSocketGraphQLSubscriptionClient.cs | 54 +++ .../FusionRequestExecutorBuilderExtensions.cs | 26 +- .../Core/Execution/FederatedQueryContext.cs | 18 +- .../src/Core/Execution/IFederationContext.cs | 7 +- .../Fusion/src/Core/Execution/WorkItem.cs | 4 +- .../src/Core/FusionResources.Designer.cs | 6 + .../Fusion/src/Core/FusionResources.resx | 3 + .../src/Core/HotChocolate.Fusion.csproj | 7 + .../Core/Metadata/FieldVariableDefinition.cs | 4 - .../FusionGraphConfigurationReader.cs | 84 +++- .../src/Core/Metadata/IVariableDefinition.cs | 5 - ...ResolverDefinition.FetchRewriterContext.cs | 36 ++ .../ResolverDefinition.ResolverRewriter.cs | 115 +++++ .../src/Core/Metadata/ResolverDefinition.cs | 151 +------ .../Fusion/src/Core/Metadata/ResolverKind.cs | 9 + .../src/Core/Planning/ExecutionPlanBuilder.cs | 62 ++- .../Core/Planning/ExportDefinitionRegistry.cs | 39 +- .../Planning/Nodes/BatchByKeyResolverNode.cs | 366 +++++++++++++++ .../Core/Planning/Nodes/QueryPlanNodeKind.cs | 2 + .../src/Core/Planning/Nodes/ResolverNode.cs | 16 +- .../src/Core/Planning/RequestPlanner.cs | 161 +++++-- .../src/Core/Planning/RequirementsPlanner.cs | 22 +- .../src/Core/Planning/SelectionSetInfo.cs | 24 - .../Composition.Tests/DemoIntegrationTests.cs | 78 +--- ...tChocolate.Fusion.Composition.Tests.csproj | 1 + ...egrationTests.Accounts_And_Reviews.graphql | 18 +- .../test/Core.Tests/DemoIntegrationTests.cs | 46 +- .../HotChocolate.Fusion.Tests.csproj | 12 +- .../Schemas/Accounts/AccountQuery.cs | 11 - ...d_Reviews_And_Products_AutoCompose.graphql | 12 +- ...nd_Reviews_And_Products_Introspection.snap | 24 +- ...eviews_And_Products_Query_TopProducts.snap | 12 +- ...d_Reviews_And_Products_Query_TypeName.snap | 12 +- ...ts.Authors_And_Reviews_AutoCompose.graphql | 10 +- ...ts.Authors_And_Reviews_Batch_Requests.snap | 128 ++++++ ...hors_And_Reviews_Query_GetUserReviews.snap | 10 +- ...Authors_And_Reviews_Query_ReviewsUser.snap | 10 +- .../test/Shared/Accounts/AccountQuery.cs | 27 ++ .../Schemas => Shared}/Accounts/User.cs | 2 +- .../Accounts/UserRepository.cs | 12 +- .../Schemas => Shared}/DemoProject.cs | 10 +- .../Schemas => Shared}/DemoSubgraph.cs | 2 +- .../HotChocolate.Fusion.Tests.Shared.csproj | 16 + .../MockHttpClientFactory.cs | 2 +- .../Schemas => Shared}/Products/Product.cs | 2 +- .../Products/ProductRepository.cs | 2 +- .../Schemas => Shared}/Products/Query.cs | 2 +- .../Schemas => Shared}/Reviews/Author.cs | 2 +- .../Schemas => Shared}/Reviews/Product.cs | 2 +- .../Schemas => Shared}/Reviews/Review.cs | 2 +- .../Schemas => Shared}/Reviews/ReviewQuery.cs | 2 +- .../Reviews/ReviewRepository.cs | 2 +- .../src/Skimmed/Extensions/TypeExtensions.cs | 3 +- .../Skimmed/Serialization/SchemaFormatter.cs | 52 +++ .../src/Skimmed/Serialization/SchemaParser.cs | 1 + .../IntrospectionClient.cs | 2 +- .../Utilities.Introspection/SchemaFeatures.cs | 2 + 92 files changed, 2075 insertions(+), 896 deletions(-) create mode 100644 src/HotChocolate/Fusion/src/Abstractions/FusionEnumValueNames.cs create mode 100644 src/HotChocolate/Fusion/src/Composition/Entities/EntityResolverKind.cs rename src/HotChocolate/Fusion/src/Core/Clients/{GraphQLHttpClient.cs => HttpGraphQLClient.cs} (55%) create mode 100644 src/HotChocolate/Fusion/src/Core/Clients/IGraphQLSubscriptionClient.cs create mode 100644 src/HotChocolate/Fusion/src/Core/Clients/InvalidContentTypeException.cs create mode 100644 src/HotChocolate/Fusion/src/Core/Clients/WebSocketGraphQLSubscriptionClient.cs create mode 100644 src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.FetchRewriterContext.cs create mode 100644 src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.ResolverRewriter.cs create mode 100644 src/HotChocolate/Fusion/src/Core/Metadata/ResolverKind.cs create mode 100644 src/HotChocolate/Fusion/src/Core/Planning/Nodes/BatchByKeyResolverNode.cs delete mode 100644 src/HotChocolate/Fusion/src/Core/Planning/SelectionSetInfo.cs delete mode 100644 src/HotChocolate/Fusion/test/Core.Tests/Schemas/Accounts/AccountQuery.cs create mode 100644 src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Batch_Requests.snap create mode 100644 src/HotChocolate/Fusion/test/Shared/Accounts/AccountQuery.cs rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/Accounts/User.cs (62%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/Accounts/UserRepository.cs (66%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/DemoProject.cs (95%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/DemoSubgraph.cs (95%) create mode 100644 src/HotChocolate/Fusion/test/Shared/HotChocolate.Fusion.Tests.Shared.csproj rename src/HotChocolate/Fusion/test/{Core.Tests => Shared}/MockHttpClientFactory.cs (89%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/Products/Product.cs (58%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/Products/ProductRepository.cs (91%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/Products/Query.cs (88%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/Reviews/Author.cs (86%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/Reviews/Product.cs (84%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/Reviews/Review.cs (61%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/Reviews/ReviewQuery.cs (90%) rename src/HotChocolate/Fusion/test/{Core.Tests/Schemas => Shared}/Reviews/ReviewRepository.cs (96%) diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.Designer.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.Designer.cs index bd2ef55133d..df627343bb2 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.Designer.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Properties/AspNetCoreResources.Designer.cs @@ -1,7 +1,6 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -10,48 +9,34 @@ namespace HotChocolate.AspNetCore.Properties { using System; - - - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class AspNetCoreResources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal AspNetCoreResources() { } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("HotChocolate.AspNetCore.Properties.AspNetCoreResources", typeof(AspNetCoreResources).Assembly); + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("HotChocolate.AspNetCore.Properties.AspNetCoreResources", typeof(AspNetCoreResources).Assembly); resourceMan = temp; } return resourceMan; } } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { get { return resourceCulture; } @@ -59,364 +44,250 @@ internal AspNetCoreResources() { resourceCulture = value; } } - - /// - /// Looks up a localized string similar to Invalid message type.. - /// - internal static string Apollo_OnReceive_InvalidMessageType { + + internal static string ThrowHelper_DefaultHttpRequestParser_RequestIsEmpty { get { - return ResourceManager.GetString("Apollo_OnReceive_InvalidMessageType", resourceCulture); + return ResourceManager.GetString("ThrowHelper_DefaultHttpRequestParser_RequestIsEmpty", resourceCulture); } } - - /// - /// Looks up a localized string similar to Invalid subscribe message structure.. - /// - internal static string Apollo_OnReceive_InvalidSubscribeMessage { + + internal static string ThrowHelper_DefaultHttpRequestParser_QueryAndIdMissing { get { - return ResourceManager.GetString("Apollo_OnReceive_InvalidSubscribeMessage", resourceCulture); + return ResourceManager.GetString("ThrowHelper_DefaultHttpRequestParser_QueryAndIdMissing", resourceCulture); } } - - /// - /// Looks up a localized string similar to A message must be a json object.. - /// - internal static string Apollo_OnReceive_MessageMustBeJson { + + internal static string ThrowHelper_DefaultHttpRequestParser_MaxRequestSizeExceeded { get { - return ResourceManager.GetString("Apollo_OnReceive_MessageMustBeJson", resourceCulture); + return ResourceManager.GetString("ThrowHelper_DefaultHttpRequestParser_MaxRequestSizeExceeded", resourceCulture); } } - - /// - /// Looks up a localized string similar to The subscription id is not unique.. - /// - internal static string Apollo_OnReceive_SubscriptionIdNotUnique { + + internal static string ThrowHelper_DataStartMessageHandler_RequestTypeNotSupported { get { - return ResourceManager.GetString("Apollo_OnReceive_SubscriptionIdNotUnique", resourceCulture); + return ResourceManager.GetString("ThrowHelper_DataStartMessageHandler_RequestTypeNotSupported", resourceCulture); } } - - /// - /// Looks up a localized string similar to Too many initialization requests.. - /// - internal static string Apollo_OnReceive_ToManyInitializations { + + internal static string ThrowHelper_HttpMultipartMiddleware_Invalid_Form { get { - return ResourceManager.GetString("Apollo_OnReceive_ToManyInitializations", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_Invalid_Form", resourceCulture); } } - - /// - /// Looks up a localized string similar to The type property on the message is mandatory.. - /// - internal static string Apollo_OnReceive_TypePropMissing { + + internal static string ThrowHelper_HttpMultipartMiddleware_No_Operations_Specified { get { - return ResourceManager.GetString("Apollo_OnReceive_TypePropMissing", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_No_Operations_Specified", resourceCulture); } } - - /// - /// Looks up a localized string similar to Message cannot be null or empty.. - /// - internal static string ConnectionStatus_Reject_Message_cannot_be_null_or_empty_ { + + internal static string ThrowHelper_HttpMultipartMiddleware_Fields_Misordered { get { - return ResourceManager.GetString("ConnectionStatus_Reject_Message_cannot_be_null_or_empty_", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_Fields_Misordered", resourceCulture); } } - - /// - /// Looks up a localized string similar to Message cannot be null or empty.. - /// - internal static string ConnectionStatus_Reject_MessageCannotBeNullOrEmpty { + + internal static string HttpMultipartMiddleware_InsertFilesIntoRequest_VariablesImmutable { get { - return ResourceManager.GetString("ConnectionStatus_Reject_MessageCannotBeNullOrEmpty", resourceCulture); + return ResourceManager.GetString("HttpMultipartMiddleware_InsertFilesIntoRequest_VariablesImmutable", resourceCulture); } } - - /// - /// Looks up a localized string similar to The specified result object is not a valid subscription result.. - /// - internal static string DataStartMessageHandler_Not_A_SubscriptionResult { + + internal static string VariablePath_Parse_FirstSegmentMustBeKey { get { - return ResourceManager.GetString("DataStartMessageHandler_Not_A_SubscriptionResult", resourceCulture); + return ResourceManager.GetString("VariablePath_Parse_FirstSegmentMustBeKey", resourceCulture); } } - - /// - /// Looks up a localized string similar to Unable to parse the accept header value `{0}`.. - /// - internal static string ErrorHelper_InvalidAcceptMediaType { + + internal static string ThrowHelper_HttpMultipartMiddleware_NoObjectPath { get { - return ResourceManager.GetString("ErrorHelper_InvalidAcceptMediaType", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_NoObjectPath", resourceCulture); } } - - /// - /// Looks up a localized string similar to Invalid GraphQL Request.. - /// - internal static string ErrorHelper_InvalidRequest { + + internal static string ThrowHelper_HttpMultipartMiddleware_FileMissing { get { - return ResourceManager.GetString("ErrorHelper_InvalidRequest", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_FileMissing", resourceCulture); } } - - /// - /// Looks up a localized string similar to The type name is invalid.. - /// - internal static string ErrorHelper_InvalidTypeName { + + internal static string ThrowHelper_HttpMultipartMiddleware_VariableNotFound { get { - return ResourceManager.GetString("ErrorHelper_InvalidTypeName", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_VariableNotFound", resourceCulture); } } - - /// - /// Looks up a localized string similar to None of the `Accept` header values is supported.. - /// - internal static string ErrorHelper_NoSupportedAcceptMediaType { + + internal static string ThrowHelper_HttpMultipartMiddleware_VariableStructureInvalid { get { - return ResourceManager.GetString("ErrorHelper_NoSupportedAcceptMediaType", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_VariableStructureInvalid", resourceCulture); } } - - /// - /// Looks up a localized string similar to The GraphQL batch request has no elements.. - /// - internal static string ErrorHelper_RequestHasNoElements { + + internal static string ThrowHelper_HttpMultipartMiddleware_InvalidPath { get { - return ResourceManager.GetString("ErrorHelper_RequestHasNoElements", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_InvalidPath", resourceCulture); } } - - /// - /// Looks up a localized string similar to The specified types argument is empty.. - /// - internal static string ErrorHelper_TypeNameIsEmpty { + + internal static string ThrowHelper_HttpMultipartMiddleware_PathMustStartWithVariable { get { - return ResourceManager.GetString("ErrorHelper_TypeNameIsEmpty", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_PathMustStartWithVariable", resourceCulture); } } - - /// - /// Looks up a localized string similar to The type `{0}` does not exist.. - /// - internal static string ErrorHelper_TypeNotFound { + + internal static string ThrowHelper_HttpMultipartMiddleware_InvalidMapJson { get { - return ResourceManager.GetString("ErrorHelper_TypeNotFound", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_InvalidMapJson", resourceCulture); } } - - /// - /// Looks up a localized string similar to The variables are expected to mutable at this point.. - /// - internal static string HttpMultipartMiddleware_InsertFilesIntoRequest_VariablesImmutable { + + internal static string ThrowHelper_HttpMultipartMiddleware_MapNotSpecified { get { - return ResourceManager.GetString("HttpMultipartMiddleware_InsertFilesIntoRequest_VariablesImmutable", resourceCulture); + return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_MapNotSpecified", resourceCulture); } } - - /// - /// Looks up a localized string similar to The sessionId mustn't be null or empty.. - /// - internal static string OperationManager_Register_SessionIdNullOrEmpty { + + internal static string ErrorHelper_InvalidRequest { get { - return ResourceManager.GetString("OperationManager_Register_SessionIdNullOrEmpty", resourceCulture); + return ResourceManager.GetString("ErrorHelper_InvalidRequest", resourceCulture); } } - - /// - /// Looks up a localized string similar to The message type cannot be null or empty.. - /// - internal static string OperationMessage_TypeCannotBeNullOrEmpty { + + internal static string ErrorHelper_RequestHasNoElements { get { - return ResourceManager.GetString("OperationMessage_TypeCannotBeNullOrEmpty", resourceCulture); + return ResourceManager.GetString("ErrorHelper_RequestHasNoElements", resourceCulture); } } - - /// - /// Looks up a localized string similar to Connection terminated by user.. - /// - internal static string TerminateConnectionMessageHandler_Message { + + internal static string WebSocketSession_SessionEnded { get { - return ResourceManager.GetString("TerminateConnectionMessageHandler_Message", resourceCulture); + return ResourceManager.GetString("WebSocketSession_SessionEnded", resourceCulture); } } - - /// - /// Looks up a localized string similar to The response type is not supported.. - /// - internal static string ThrowHelper_DataStartMessageHandler_RequestTypeNotSupported { + + internal static string DataStartMessageHandler_Not_A_SubscriptionResult { get { - return ResourceManager.GetString("ThrowHelper_DataStartMessageHandler_RequestTypeNotSupported", resourceCulture); + return ResourceManager.GetString("DataStartMessageHandler_Not_A_SubscriptionResult", resourceCulture); } } - - /// - /// Looks up a localized string similar to Max GraphQL request size reached.. - /// - internal static string ThrowHelper_DefaultHttpRequestParser_MaxRequestSizeExceeded { + + internal static string OperationMessage_TypeCannotBeNullOrEmpty { get { - return ResourceManager.GetString("ThrowHelper_DefaultHttpRequestParser_MaxRequestSizeExceeded", resourceCulture); + return ResourceManager.GetString("OperationMessage_TypeCannotBeNullOrEmpty", resourceCulture); } } - - /// - /// Looks up a localized string similar to Either the parameter query or the parameter id has to be set.. - /// - internal static string ThrowHelper_DefaultHttpRequestParser_QueryAndIdMissing { + + internal static string TerminateConnectionMessageHandler_Message { get { - return ResourceManager.GetString("ThrowHelper_DefaultHttpRequestParser_QueryAndIdMissing", resourceCulture); + return ResourceManager.GetString("TerminateConnectionMessageHandler_Message", resourceCulture); } } - - /// - /// Looks up a localized string similar to The GraphQL request is empty.. - /// - internal static string ThrowHelper_DefaultHttpRequestParser_RequestIsEmpty { + + internal static string OperationManager_Register_SessionIdNullOrEmpty { get { - return ResourceManager.GetString("ThrowHelper_DefaultHttpRequestParser_RequestIsEmpty", resourceCulture); + return ResourceManager.GetString("OperationManager_Register_SessionIdNullOrEmpty", resourceCulture); } } - - /// - /// Looks up a localized string similar to Invalid accept media types specified.. - /// - internal static string ThrowHelper_Formatter_InvalidAcceptMediaType { + + internal static string Apollo_OnReceive_MessageMustBeJson { get { - return ResourceManager.GetString("ThrowHelper_Formatter_InvalidAcceptMediaType", resourceCulture); + return ResourceManager.GetString("Apollo_OnReceive_MessageMustBeJson", resourceCulture); } } - - /// - /// Looks up a localized string similar to The specified response content-type `{0}` is not supported.. - /// - internal static string ThrowHelper_Formatter_ResponseContentTypeNotSupported { + + internal static string Apollo_OnReceive_TypePropMissing { get { - return ResourceManager.GetString("ThrowHelper_Formatter_ResponseContentTypeNotSupported", resourceCulture); + return ResourceManager.GetString("Apollo_OnReceive_TypePropMissing", resourceCulture); } } - - /// - /// Looks up a localized string similar to The execution result kind is not supported.. - /// - internal static string ThrowHelper_Formatter_ResultKindNotSupported { + + internal static string Apollo_OnReceive_ToManyInitializations { get { - return ResourceManager.GetString("ThrowHelper_Formatter_ResultKindNotSupported", resourceCulture); + return ResourceManager.GetString("Apollo_OnReceive_ToManyInitializations", resourceCulture); } } - - /// - /// Looks up a localized string similar to Misordered multipart fields; 'map' should follow 'operations'.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_Fields_Misordered { + + internal static string Apollo_OnReceive_InvalidSubscribeMessage { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_Fields_Misordered", resourceCulture); + return ResourceManager.GetString("Apollo_OnReceive_InvalidSubscribeMessage", resourceCulture); } } - - /// - /// Looks up a localized string similar to File of key '{0}' is missing.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_FileMissing { + + internal static string Apollo_OnReceive_SubscriptionIdNotUnique { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_FileMissing", resourceCulture); + return ResourceManager.GetString("Apollo_OnReceive_SubscriptionIdNotUnique", resourceCulture); } } - - /// - /// Looks up a localized string similar to The multipart form could not be read.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_Invalid_Form { + + internal static string Apollo_OnReceive_InvalidMessageType { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_Invalid_Form", resourceCulture); + return ResourceManager.GetString("Apollo_OnReceive_InvalidMessageType", resourceCulture); } } - - /// - /// Looks up a localized string similar to Invalid JSON in the `map` multipart field; Expected type of Dictionary<string, string[]>.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_InvalidMapJson { + + internal static string ConnectionStatus_Reject_Message_cannot_be_null_or_empty_ { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_InvalidMapJson", resourceCulture); + return ResourceManager.GetString("ConnectionStatus_Reject_Message_cannot_be_null_or_empty_", resourceCulture); } } - - /// - /// Looks up a localized string similar to Invalid variable path `{0}` in `map`.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_InvalidPath { + + internal static string ConnectionStatus_Reject_MessageCannotBeNullOrEmpty { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_InvalidPath", resourceCulture); + return ResourceManager.GetString("ConnectionStatus_Reject_MessageCannotBeNullOrEmpty", resourceCulture); } } - - /// - /// Looks up a localized string similar to No `map` specified.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_MapNotSpecified { + + internal static string ErrorHelper_NoSupportedAcceptMediaType { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_MapNotSpecified", resourceCulture); + return ResourceManager.GetString("ErrorHelper_NoSupportedAcceptMediaType", resourceCulture); } } - - /// - /// Looks up a localized string similar to No 'operations' specified.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_No_Operations_Specified { + + internal static string ThrowHelper_Formatter_ResultKindNotSupported { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_No_Operations_Specified", resourceCulture); + return ResourceManager.GetString("ThrowHelper_Formatter_ResultKindNotSupported", resourceCulture); } } - - /// - /// Looks up a localized string similar to No object paths specified for key '{0}' in 'map'.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_NoObjectPath { + + internal static string ThrowHelper_Formatter_ResponseContentTypeNotSupported { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_NoObjectPath", resourceCulture); + return ResourceManager.GetString("ThrowHelper_Formatter_ResponseContentTypeNotSupported", resourceCulture); } } - - /// - /// Looks up a localized string similar to The variable path must start with `variables`.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_PathMustStartWithVariable { + + internal static string ThrowHelper_Formatter_InvalidAcceptMediaType { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_PathMustStartWithVariable", resourceCulture); + return ResourceManager.GetString("ThrowHelper_Formatter_InvalidAcceptMediaType", resourceCulture); } } - - /// - /// Looks up a localized string similar to The variable path '{0}' is invalid.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_VariableNotFound { + + internal static string ErrorHelper_InvalidAcceptMediaType { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_VariableNotFound", resourceCulture); + return ResourceManager.GetString("ErrorHelper_InvalidAcceptMediaType", resourceCulture); } } - - /// - /// Looks up a localized string similar to The variable structure is invalid.. - /// - internal static string ThrowHelper_HttpMultipartMiddleware_VariableStructureInvalid { + + internal static string ErrorHelper_TypeNotFound { get { - return ResourceManager.GetString("ThrowHelper_HttpMultipartMiddleware_VariableStructureInvalid", resourceCulture); + return ResourceManager.GetString("ErrorHelper_TypeNotFound", resourceCulture); } } - - /// - /// Looks up a localized string similar to The first path segment must be a key.. - /// - internal static string VariablePath_Parse_FirstSegmentMustBeKey { + + internal static string ErrorHelper_InvalidTypeName { get { - return ResourceManager.GetString("VariablePath_Parse_FirstSegmentMustBeKey", resourceCulture); + return ResourceManager.GetString("ErrorHelper_InvalidTypeName", resourceCulture); } } - - /// - /// Looks up a localized string similar to Session ended.. - /// - internal static string WebSocketSession_SessionEnded { + + internal static string ErrorHelper_TypeNameIsEmpty { get { - return ResourceManager.GetString("WebSocketSession_SessionEnded", resourceCulture); + return ResourceManager.GetString("ErrorHelper_TypeNameIsEmpty", resourceCulture); + } + } + + internal static string GraphQLHttpClient_InvalidContentType { + get { + return ResourceManager.GetString("GraphQLHttpClient_InvalidContentType", resourceCulture); } } } diff --git a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/HotChocolate.Transport.Sockets.Client.csproj b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/HotChocolate.Transport.Sockets.Client.csproj index 57bef7162c4..e137e810201 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/HotChocolate.Transport.Sockets.Client.csproj +++ b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/HotChocolate.Transport.Sockets.Client.csproj @@ -12,6 +12,7 @@ + diff --git a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/OperationRequest.cs b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/OperationRequest.cs index 286ab17a966..def1604a04d 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/OperationRequest.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/OperationRequest.cs @@ -1,11 +1,29 @@ using System; using System.Collections.Generic; +using HotChocolate.Language; using static HotChocolate.Transport.Sockets.Client.Properties.SocketClientResources; namespace HotChocolate.Transport.Sockets.Client; public readonly struct OperationRequest : IEquatable { + public OperationRequest( + string? query, + string? id, + ObjectValueNode? variables, + ObjectValueNode? extensions) + { + if (query is null && id is null && extensions is null) + { + throw new ArgumentException(OperationRequest_QueryOrPersistedQueryId, nameof(query)); + } + + Query = query; + Id = id; + VariablesNode = variables; + ExtensionsNode = extensions; + } + public OperationRequest( string? query = null, string? id = null, @@ -29,8 +47,12 @@ public OperationRequest( public IReadOnlyDictionary? Variables { get; } + public ObjectValueNode? VariablesNode { get; } + public IReadOnlyDictionary? Extensions { get; } + public ObjectValueNode? ExtensionsNode { get; } + public bool Equals(OperationRequest other) => Id == other.Id && Query == other.Query && diff --git a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/DataMessageObserver.cs b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/DataMessageObserver.cs index c84e6f54b6b..fe791ca0996 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/DataMessageObserver.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/DataMessageObserver.cs @@ -33,7 +33,7 @@ public DataMessageObserver(string id) throw _error; } - _messages.TryDequeue(out IDataMessage? message); + _messages.TryDequeue(out var message); return message; } diff --git a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/GraphQLOverWebSocketProtocolHandler.cs b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/GraphQLOverWebSocketProtocolHandler.cs index 284d7024368..65bbf4bcff6 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/GraphQLOverWebSocketProtocolHandler.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/GraphQLOverWebSocketProtocolHandler.cs @@ -19,7 +19,7 @@ public async ValueTask InitializeAsync( CancellationToken cancellationToken = default) { var observer = new ConnectionMessageObserver(cancellationToken); - using IDisposable subscription = context.Messages.Subscribe(observer); + using var subscription = context.Messages.Subscribe(observer); await context.Socket.SendConnectionInitMessage(payload, cancellationToken); await observer.Accepted; } @@ -32,7 +32,7 @@ public async ValueTask ExecuteAsync( var id = Guid.NewGuid().ToString("N"); var observer = new DataMessageObserver(id); var completion = new DataCompletion(context.Socket, id); - IDisposable subscription = context.Messages.Subscribe(observer); + var subscription = context.Messages.Subscribe(observer); await context.Socket.SendSubscribeMessageAsync(id, request, cancellationToken); @@ -62,9 +62,9 @@ public ValueTask OnReceiveAsync( try { document = JsonDocument.Parse(message); - JsonElement root = document.RootElement; + var root = document.RootElement; - if (root.TryGetProperty(TypeProp, out JsonElement typeProp)) + if (root.TryGetProperty(TypeProp, out var typeProp)) { if (typeProp.ValueEquals(Utf8Messages.Ping)) { diff --git a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/MessageHelper.cs b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/MessageHelper.cs index 44cbaed470b..d4787bf0f7e 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/MessageHelper.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/MessageHelper.cs @@ -25,7 +25,7 @@ public static async ValueTask SendConnectionInitMessage( jsonWriter.WritePropertyName(PayloadProp); JsonSerializer.Serialize(jsonWriter, payload, JsonDefaults.SerializerOptions); } - + jsonWriter.WriteEndObject(); await jsonWriter.FlushAsync(ct).ConfigureAwait(false); @@ -44,6 +44,7 @@ public static async ValueTask SendSubscribeMessageAsync( { using var arrayWriter = new ArrayWriter(); await using var jsonWriter = new Utf8JsonWriter(arrayWriter, JsonDefaults.WriterOptions); + jsonWriter.WriteStartObject(); jsonWriter.WriteString(IdProp, operationSessionId); jsonWriter.WriteString(TypeProp, Utf8Messages.Subscribe); diff --git a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/CompleteMessage.cs b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/CompleteMessage.cs index 60c03ec251a..71140114356 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/CompleteMessage.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/CompleteMessage.cs @@ -15,7 +15,7 @@ private CompleteMessage(string id) public static CompleteMessage From(JsonDocument document) { - JsonElement root = document.RootElement; + var root = document.RootElement; var id = root.GetProperty(Utf8MessageProperties.IdProp).GetString()!; return new CompleteMessage(id); } diff --git a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/ErrorMessage.cs b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/ErrorMessage.cs index 06ada30a62f..2f7e9c74541 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/ErrorMessage.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/ErrorMessage.cs @@ -18,10 +18,10 @@ private ErrorMessage(string id, OperationResult payload) public static ErrorMessage From(JsonDocument document) { - JsonElement root = document.RootElement; + var root = document.RootElement; var id = root.GetProperty(Utf8MessageProperties.IdProp).GetString()!; - JsonElement payload = root.GetProperty(Utf8MessageProperties.PayloadProp); + var payload = root.GetProperty(Utf8MessageProperties.PayloadProp); var result = new OperationResult(document, errors: payload); return new ErrorMessage(id, result); diff --git a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/NextMessage.cs b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/NextMessage.cs index d509c87f972..9d2641ea26c 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/NextMessage.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/GraphQLOverWebSocket/Messages/NextMessage.cs @@ -20,10 +20,10 @@ private NextMessage(string id, OperationResult payload) public static NextMessage From(JsonDocument document) { - JsonElement root = document.RootElement; + var root = document.RootElement; var id = root.GetProperty(IdProp).GetString()!; - JsonElement payload = root.GetProperty(PayloadProp); + var payload = root.GetProperty(PayloadProp); var result = new OperationResult( document, TryGetProperty(payload, DataProp), @@ -34,7 +34,7 @@ public static NextMessage From(JsonDocument document) } private static JsonElement? TryGetProperty(JsonElement element, ReadOnlySpan name) - => element.TryGetProperty(name, out JsonElement property) + => element.TryGetProperty(name, out var property) ? property : null; } diff --git a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/MessageStream.cs b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/MessageStream.cs index f7a28b4d14a..e820b2e1a0c 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/MessageStream.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/Protocols/MessageStream.cs @@ -33,7 +33,7 @@ private void Unsubscribe(Subscription subscription) private void OnNext(IOperationMessage value, ImmutableList subscriptions) { - foreach (Subscription subscription in subscriptions) + foreach (var subscription in subscriptions) { subscription.Observer.OnNext(value); } @@ -43,7 +43,7 @@ private void OnNext(IOperationMessage value, ImmutableList subscri private void OnError(Exception error, ImmutableList subscriptions) { - foreach (Subscription subscription in subscriptions) + foreach (var subscription in subscriptions) { subscription.Observer.OnError(error); } @@ -53,7 +53,7 @@ private void OnError(Exception error, ImmutableList subscriptions) private void OnCompleted(ImmutableList subscriptions) { - foreach (Subscription subscription in subscriptions) + foreach (var subscription in subscriptions) { subscription.Observer.OnCompleted(); } diff --git a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/SocketClient.cs b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/SocketClient.cs index cdead3c63cb..1c5f58cd6dc 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/SocketClient.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Sockets.Client/SocketClient.cs @@ -11,7 +11,7 @@ namespace HotChocolate.Transport.Sockets.Client; -public class SocketClient : ISocket +public sealed class SocketClient : ISocket { private static readonly IProtocolHandler[] _protocolHandlers = { @@ -156,7 +156,7 @@ async Task ISocket.ReadMessageAsync( socketResult = await _socket.ReceiveAsync(arraySegment, cancellationToken); // copy message segment to writer. - Memory memory = writer.GetMemory(socketResult.Count); + var memory = writer.GetMemory(socketResult.Count); buffer.AsSpan().Slice(0, socketResult.Count).CopyTo(memory.Span); writer.Advance(socketResult.Count); read += socketResult.Count; diff --git a/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs b/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs index 71b7f6b7a75..7512033e370 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs @@ -208,12 +208,28 @@ private Operation CreateOperation( variants[item.Key] = item.Value; } + // we will complete the selection variants, sets and selections + // without sealing them so that analyzers in this step can fully + // inspect them. + var variantsSpan = variants.AsSpan(); + ref var variantsStart = ref GetReference(variantsSpan); + ref var variantsEnd = ref Unsafe.Add(ref variantsStart, variantsSpan.Length); + + while (Unsafe.IsAddressLessThan(ref variantsStart, ref variantsEnd)) + { + variantsStart.Complete(); + variantsStart = ref Unsafe.Add(ref variantsStart, 1); + } + #if NET5_0_OR_GREATER - ref var optSpace = ref GetReference(AsSpan(_operationOptimizers)); + var optSpan = AsSpan(_operationOptimizers); + ref var optStart = ref GetReference(optSpan); + ref var optEnd = ref Unsafe.Add(ref optStart, optSpan.Length); - for (var i = 0; i < _operationOptimizers.Count; i++) + while (Unsafe.IsAddressLessThan(ref optStart, ref optEnd)) { - Unsafe.Add(ref optSpace, i).OptimizeOperation(context); + optStart.OptimizeOperation(context); + optStart = ref Unsafe.Add(ref optStart, 1); } #else for (var i = 0; i < _operationOptimizers.Count; i++) @@ -224,11 +240,14 @@ private Operation CreateOperation( CompleteResolvers(schema); - ref var varSpace = ref GetReference(variants.AsSpan()); + variantsSpan = variants.AsSpan(); + variantsStart = ref GetReference(variantsSpan); + variantsEnd = ref Unsafe.Add(ref variantsStart, variantsSpan.Length); - for (var i = 0; i < _operationOptimizers.Count; i++) + while (Unsafe.IsAddressLessThan(ref variantsStart, ref variantsEnd)) { - Unsafe.Add(ref varSpace, i).Seal(); + variantsStart.Seal(); + variantsStart = ref Unsafe.Add(ref variantsStart, 1); } } diff --git a/src/HotChocolate/Core/src/Execution/Processing/Selection.cs b/src/HotChocolate/Core/src/Execution/Processing/Selection.cs index 6714658c023..4cb3a073348 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/Selection.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/Selection.cs @@ -1,12 +1,10 @@ using System; -using System.Collections.Generic; using System.Diagnostics; using System.Linq; using HotChocolate.Execution.Properties; using HotChocolate.Language; using HotChocolate.Resolvers; using HotChocolate.Types; -using Microsoft.Extensions.ObjectPool; namespace HotChocolate.Execution.Processing; @@ -313,6 +311,23 @@ internal void MarkAsStream(long ifCondition) _flags |= Flags.Stream; } + /// + /// Completes the selection without sealing it. + /// + internal void Complete(ISelectionSet declaringSelectionSet) + { + Debug.Assert(declaringSelectionSet is not null); + + if ((_flags & Flags.Sealed) != Flags.Sealed) + { + DeclaringSelectionSet = declaringSelectionSet; + } + + Debug.Assert( + ReferenceEquals(declaringSelectionSet, DeclaringSelectionSet), + "Selections can only belong to a single selectionSet."); + } + internal void Seal(ISelectionSet declaringSelectionSet) { if ((_flags & Flags.Sealed) != Flags.Sealed) diff --git a/src/HotChocolate/Core/src/Execution/Processing/SelectionSet.cs b/src/HotChocolate/Core/src/Execution/Processing/SelectionSet.cs index 35c0c27532c..dc0e13bcc59 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/SelectionSet.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/SelectionSet.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using HotChocolate.Language; namespace HotChocolate.Execution.Processing; @@ -56,6 +57,20 @@ public SelectionSet( /// public IReadOnlyList Fragments => _fragments; + /// + /// Completes the selection set without sealing it. + /// + internal void Complete() + { + if ((_flags & Flags.Sealed) != Flags.Sealed) + { + for (var i = 0; i < _selections.Length; i++) + { + _selections[i].Complete(this); + } + } + } + internal void Seal() { if ((_flags & Flags.Sealed) != Flags.Sealed) diff --git a/src/HotChocolate/Core/src/Execution/Processing/SelectionVariants.cs b/src/HotChocolate/Core/src/Execution/Processing/SelectionVariants.cs index a0d8ba77787..f7cd86c1223 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/SelectionVariants.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/SelectionVariants.cs @@ -9,9 +9,9 @@ namespace HotChocolate.Execution.Processing; internal sealed class SelectionVariants : ISelectionVariants { private IObjectType? _firstType; - private SelectionSet? _firstSelections; + private SelectionSet? _firstSelectionSet; private IObjectType? _secondType; - private SelectionSet? _secondSelections; + private SelectionSet? _secondSelectionSet; private Dictionary? _map; private bool _readOnly; @@ -46,12 +46,12 @@ public ISelectionSet GetSelectionSet(IObjectType typeContext) if (ReferenceEquals(_firstType, typeContext)) { - return _firstSelections!; + return _firstSelectionSet!; } if (ReferenceEquals(_secondType, typeContext)) { - return _secondSelections!; + return _secondSelectionSet!; } throw SelectionSet_TypeContextInvalid(typeContext); @@ -100,7 +100,7 @@ internal void AddSelectionSet( if (_firstType is null) { _firstType = typeContext; - _firstSelections = selectionSet; + _firstSelectionSet = selectionSet; } else if (_secondType is null) { @@ -110,21 +110,41 @@ internal void AddSelectionSet( } _secondType = typeContext; - _secondSelections = selectionSet; + _secondSelectionSet = selectionSet; } else { _map = new Dictionary { - { _firstType, _firstSelections! }, - { _secondType, _secondSelections! }, + { _firstType, _firstSelectionSet! }, + { _secondType, _secondSelectionSet! }, { typeContext, selectionSet } }; _firstType = null; - _firstSelections = null; + _firstSelectionSet = null; _secondType = null; - _secondSelections = null; + _secondSelectionSet = null; + } + } + } + + /// + /// Completes the selection variant without sealing it. + /// + internal void Complete() + { + if (!_readOnly) + { + _firstSelectionSet?.Complete(); + _secondSelectionSet?.Complete(); + + if (_map is not null) + { + foreach (var selectionSet in _map.Values) + { + selectionSet.Complete(); + } } } } @@ -133,8 +153,8 @@ internal void Seal() { if (!_readOnly) { - _firstSelections?.Seal(); - _secondSelections?.Seal(); + _firstSelectionSet?.Seal(); + _secondSelectionSet?.Seal(); if (_map is not null) { diff --git a/src/HotChocolate/Fusion/HotChocolate.Fusion.sln b/src/HotChocolate/Fusion/HotChocolate.Fusion.sln index de70d53b3aa..7f110c5a6a8 100644 --- a/src/HotChocolate/Fusion/HotChocolate.Fusion.sln +++ b/src/HotChocolate/Fusion/HotChocolate.Fusion.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.Fusion.Abstrac EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.Fusion.Abstractions.Tests", "test\Abstractions.Tests\HotChocolate.Fusion.Abstractions.Tests.csproj", "{8DB4AD09-9CCE-4A0D-A169-4578167514B8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.Fusion.Tests.Shared", "test\Shared\HotChocolate.Fusion.Tests.Shared.csproj", "{0A07E4BB-0CFE-406C-B3F4-E26D0100F6F9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -52,6 +54,10 @@ Global {8DB4AD09-9CCE-4A0D-A169-4578167514B8}.Debug|Any CPU.Build.0 = Debug|Any CPU {8DB4AD09-9CCE-4A0D-A169-4578167514B8}.Release|Any CPU.ActiveCfg = Release|Any CPU {8DB4AD09-9CCE-4A0D-A169-4578167514B8}.Release|Any CPU.Build.0 = Release|Any CPU + {0A07E4BB-0CFE-406C-B3F4-E26D0100F6F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A07E4BB-0CFE-406C-B3F4-E26D0100F6F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A07E4BB-0CFE-406C-B3F4-E26D0100F6F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A07E4BB-0CFE-406C-B3F4-E26D0100F6F9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {0355AF0F-B91D-4852-8C9F-8E13CE5C88F3} = {748FCFC6-3EE7-4CFD-AFB3-B0F7B1ACD026} @@ -60,5 +66,6 @@ Global {DEE7F756-AF1B-46DA-944E-95B91A8E562A} = {0EF9C546-286E-407F-A02E-731804507FDE} {63B597BD-DCFE-49CD-92A2-D819236B1643} = {748FCFC6-3EE7-4CFD-AFB3-B0F7B1ACD026} {8DB4AD09-9CCE-4A0D-A169-4578167514B8} = {0EF9C546-286E-407F-A02E-731804507FDE} + {0A07E4BB-0CFE-406C-B3F4-E26D0100F6F9} = {0EF9C546-286E-407F-A02E-731804507FDE} EndGlobalSection EndGlobal diff --git a/src/HotChocolate/Fusion/src/Abstractions/FusionDirectiveArgumentNames.cs b/src/HotChocolate/Fusion/src/Abstractions/FusionDirectiveArgumentNames.cs index 85b1f3afa90..4bd4ed2c856 100644 --- a/src/HotChocolate/Fusion/src/Abstractions/FusionDirectiveArgumentNames.cs +++ b/src/HotChocolate/Fusion/src/Abstractions/FusionDirectiveArgumentNames.cs @@ -20,6 +20,11 @@ internal static class FusionDirectiveArgumentNames /// public const string ArgumentArg = "argument"; + /// + /// Gets the name of the arguments argument. + /// + public const string ArgumentsArg = "arguments"; + /// /// Gets the name of the type argument. /// @@ -30,6 +35,11 @@ internal static class FusionDirectiveArgumentNames /// public const string SubgraphArg = "subgraph"; + /// + /// Gets the name of the kind argument. + /// + public const string KindArg = "kind"; + /// /// Gets the name of the prefix argument. /// @@ -50,4 +60,3 @@ internal static class FusionDirectiveArgumentNames /// public const string BaseAddressArg = "baseAddress"; } - diff --git a/src/HotChocolate/Fusion/src/Abstractions/FusionEnumValueNames.cs b/src/HotChocolate/Fusion/src/Abstractions/FusionEnumValueNames.cs new file mode 100644 index 00000000000..5c76cd2157c --- /dev/null +++ b/src/HotChocolate/Fusion/src/Abstractions/FusionEnumValueNames.cs @@ -0,0 +1,22 @@ +namespace HotChocolate.Fusion; + +/// +/// Defines the names of the values that can be used with the fusion resolver kind enum. +/// +internal static class FusionEnumValueNames +{ + /// + /// Gets the name of the query resolver kind. + /// + public const string Query = "QUERY"; + + /// + /// Gets the name of the batch resolver kind. + /// + public const string Batch = "BATCH"; + + /// + /// Gets the name of the batch by key resolver kind. + /// + public const string BatchByKey = "BATCH_BY_KEY"; +} diff --git a/src/HotChocolate/Fusion/src/Abstractions/FusionTypeBaseNames.cs b/src/HotChocolate/Fusion/src/Abstractions/FusionTypeBaseNames.cs index 6b8ded6f370..a5d5c288f2b 100644 --- a/src/HotChocolate/Fusion/src/Abstractions/FusionTypeBaseNames.cs +++ b/src/HotChocolate/Fusion/src/Abstractions/FusionTypeBaseNames.cs @@ -59,5 +59,15 @@ internal static class FusionTypeBaseNames /// The base name of the URI scalar. /// public const string Uri = "Uri"; + + /// + /// The base name of the ArgumentDefinition input. + /// + public const string ArgumentDefinition = "ArgumentDefinition"; + + /// + /// The base name of the ResolverKind input. + /// + public const string ResolverKind = "ResolverKind"; } diff --git a/src/HotChocolate/Fusion/src/Abstractions/FusionTypeNames.cs b/src/HotChocolate/Fusion/src/Abstractions/FusionTypeNames.cs index 0f400fcca12..35649b92a15 100644 --- a/src/HotChocolate/Fusion/src/Abstractions/FusionTypeNames.cs +++ b/src/HotChocolate/Fusion/src/Abstractions/FusionTypeNames.cs @@ -24,7 +24,9 @@ private FusionTypeNames( string selectionSetScalar, string typeNameScalar, string typeScalar, - string uriScalar) + string uriScalar, + string argumentDefinition, + string resolverKind) { Prefix = prefix; VariableDirective = variableDirective; @@ -38,6 +40,8 @@ private FusionTypeNames( TypeNameScalar = typeNameScalar; TypeScalar = typeScalar; UriScalar = uriScalar; + ArgumentDefinition = argumentDefinition; + ResolverKind = resolverKind; _fusionDirectives.Add(variableDirective); _fusionDirectives.Add(fetchDirective); @@ -113,6 +117,16 @@ private FusionTypeNames( /// public string UriScalar { get; } + /// + /// Gets the name of the GraphQL type scalar. + /// + public string ArgumentDefinition { get; } + + /// + /// Gets the name of the URI type scalar. + /// + public string ResolverKind { get; } + /// /// Specifies if the represents a fusion directive. /// @@ -157,7 +171,9 @@ public static FusionTypeNames Create(string? prefix = null, bool prefixSelf = fa $"{prefix}_{FusionTypeBaseNames.SelectionSet}", $"{prefix}_{FusionTypeBaseNames.TypeName}", $"{prefix}_{FusionTypeBaseNames.Type}", - $"{prefix}_{FusionTypeBaseNames.Uri}"); + $"{prefix}_{FusionTypeBaseNames.Uri}", + $"{prefix}_{FusionTypeBaseNames.ArgumentDefinition}", + $"{prefix}_{FusionTypeBaseNames.ResolverKind}"); } return new FusionTypeNames( @@ -172,7 +188,9 @@ public static FusionTypeNames Create(string? prefix = null, bool prefixSelf = fa $"_{FusionTypeBaseNames.SelectionSet}", $"_{FusionTypeBaseNames.TypeName}", $"_{FusionTypeBaseNames.Type}", - $"_{FusionTypeBaseNames.Uri}"); + $"_{FusionTypeBaseNames.Uri}", + $"_{FusionTypeBaseNames.ArgumentDefinition}", + $"_{FusionTypeBaseNames.ResolverKind}"); } public static FusionTypeNames From(DocumentNode document) diff --git a/src/HotChocolate/Fusion/src/Composition/Entities/EntityResolver.cs b/src/HotChocolate/Fusion/src/Composition/Entities/EntityResolver.cs index f344f10494c..04e7f60cccf 100644 --- a/src/HotChocolate/Fusion/src/Composition/Entities/EntityResolver.cs +++ b/src/HotChocolate/Fusion/src/Composition/Entities/EntityResolver.cs @@ -10,16 +10,32 @@ internal sealed class EntityResolver /// /// Initializes a new instance of the class. /// - /// The selection set for the entity. - /// The name of the entity being resolved. - /// The name of the schema that contains the entity. - public EntityResolver(SelectionSetNode selectionSet, string entityName, string subgraph) + /// + /// The kind of entity resolver. This is used to determine how to resolve the entity. + /// + /// + /// The selection set for the entity. + /// + /// + /// The name of the entity being resolved. + /// + /// + /// The name of the schema that contains the entity. + /// + public EntityResolver( + EntityResolverKind kind, + SelectionSetNode selectionSet, + string entityName, + string subgraphName) { - SelectionSet = selectionSet; - EntityName = entityName; - Subgraph = subgraph; + Kind = kind; + SelectionSet = selectionSet ?? throw new ArgumentNullException(nameof(selectionSet)); + EntityName = entityName ?? throw new ArgumentNullException(nameof(entityName)); + SubgraphName = subgraphName ?? throw new ArgumentNullException(nameof(subgraphName)); } + public EntityResolverKind Kind { get; } + /// /// Gets the selection set that specifies how to retrieve data for the entity. /// @@ -33,7 +49,7 @@ public EntityResolver(SelectionSetNode selectionSet, string entityName, string s /// /// Gets the name of the subgraph that contains data for this entity. /// - public string Subgraph { get; } + public string SubgraphName { get; } /// /// Gets the variables used in the resolver. @@ -46,16 +62,16 @@ public EntityResolver(SelectionSetNode selectionSet, string entityName, string s /// A string representation of the entity resolver. public override string ToString() { - var definitions = new List(); - - definitions.Add( + var definitions = new List + { new OperationDefinitionNode( null, null, OperationType.Query, Variables.Select(t => t.Value.Definition).ToList(), - new[] { new DirectiveNode("schema", new ArgumentNode("name", Subgraph)) }, - SelectionSet)); + new[] { new DirectiveNode("schema", new ArgumentNode("name", SubgraphName)) }, + SelectionSet) + }; if (Variables.Count > 0) { diff --git a/src/HotChocolate/Fusion/src/Composition/Entities/EntityResolverKind.cs b/src/HotChocolate/Fusion/src/Composition/Entities/EntityResolverKind.cs new file mode 100644 index 00000000000..788bd5f3211 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Composition/Entities/EntityResolverKind.cs @@ -0,0 +1,8 @@ +namespace HotChocolate.Fusion.Composition; + +public enum EntityResolverKind +{ + Single = 0, + Batch = 1, + BatchWithKey = 2 +} diff --git a/src/HotChocolate/Fusion/src/Composition/FusionTypes.cs b/src/HotChocolate/Fusion/src/Composition/FusionTypes.cs index 8e4d96d1312..77939a34b27 100644 --- a/src/HotChocolate/Fusion/src/Composition/FusionTypes.cs +++ b/src/HotChocolate/Fusion/src/Composition/FusionTypes.cs @@ -53,15 +53,18 @@ public FusionTypes(Schema fusionGraph, string? prefix = null, bool prefixSelf = TypeName = RegisterScalarType(names.TypeNameScalar); Type = RegisterScalarType(names.TypeScalar); Uri = RegisterScalarType(names.UriScalar); + ArgumentDefinition = RegisterArgumentDefType(names.ArgumentDefinition, TypeName, Type); + ResolverKind = RegisterResolverKindType(names.ResolverKind); Resolver = RegisterResolverDirectiveType( names.ResolverDirective, SelectionSet, - TypeName); + ArgumentDefinition, + SelectionSet, + ResolverKind); Variable = RegisterVariableDirectiveType( names.VariableDirective, TypeName, - Selection, - Type); + Selection); Source = RegisterSourceDirectiveType( names.SourceDirective, TypeName); @@ -88,6 +91,10 @@ public FusionTypes(Schema fusionGraph, string? prefix = null, bool prefixSelf = public ScalarType Uri { get; } + public InputObjectType ArgumentDefinition { get; } + + public EnumType ResolverKind { get; } + public DirectiveType Resolver { get; } public DirectiveType Variable { get; } @@ -106,42 +113,60 @@ private ScalarType RegisterScalarType(string name) return scalarType; } + private InputObjectType RegisterArgumentDefType( + string name, + ScalarType typeName, + ScalarType type) + { + var argumentDef = new InputObjectType(name); + argumentDef.Fields.Add(new InputField(NameArg, new NonNullType(typeName))); + argumentDef.Fields.Add(new InputField(TypeArg, new NonNullType(type))); + argumentDef.ContextData.Add(WellKnownContextData.IsFusionType, true); + _fusionGraph.Types.Add(argumentDef); + return argumentDef; + } + + private EnumType RegisterResolverKindType(string name) + { + var resolverKind = new EnumType(name); + resolverKind.Values.Add(new EnumValue(FusionEnumValueNames.Query)); + resolverKind.Values.Add(new EnumValue(FusionEnumValueNames.Batch)); + resolverKind.Values.Add(new EnumValue(FusionEnumValueNames.BatchByKey)); + resolverKind.ContextData.Add(WellKnownContextData.IsFusionType, true); + _fusionGraph.Types.Add(resolverKind); + return resolverKind; + } + public Directive CreateVariableDirective( string subgraphName, string variableName, - FieldNode select, - ITypeNode type) + FieldNode select) => new Directive( Variable, new Argument(SubgraphArg, subgraphName), new Argument(NameArg, variableName), - new Argument(SelectArg, select.ToString(false)), - new Argument(TypeArg, type.ToString(false))); + new Argument(SelectArg, select.ToString(false))); public Directive CreateVariableDirective( string subgraphName, string variableName, - string argumentName, - ITypeNode type) + string argumentName) => new Directive( Variable, new Argument(SubgraphArg, subgraphName), new Argument(NameArg, variableName), - new Argument(ArgumentArg, argumentName), - new Argument(TypeArg, type.ToString(false))); + new Argument(ArgumentArg, argumentName)); private DirectiveType RegisterVariableDirectiveType( string name, ScalarType typeName, - ScalarType selection, - ScalarType type) + ScalarType selection) { var directiveType = new DirectiveType(name); directiveType.Arguments.Add(new InputField(NameArg, new NonNullType(typeName))); directiveType.Arguments.Add(new InputField(SelectArg, selection)); directiveType.Arguments.Add(new InputField(ArgumentArg, typeName)); directiveType.Arguments.Add(new InputField(SubgraphArg, new NonNullType(typeName))); - directiveType.Arguments.Add(new InputField(TypeArg, new NonNullType(type))); directiveType.Locations |= DirectiveLocation.Object; directiveType.Locations |= DirectiveLocation.FieldDefinition; directiveType.ContextData.Add(WellKnownContextData.IsFusionType, true); @@ -151,20 +176,63 @@ private DirectiveType RegisterVariableDirectiveType( public Directive CreateResolverDirective( string subgraphName, - SelectionSetNode select) - => new Directive( - Resolver, - new Argument(SubgraphArg, subgraphName), - new Argument(SelectArg, select.ToString(false))); + SelectionSetNode select, + Dictionary? arguments = null, + EntityResolverKind kind = EntityResolverKind.Single) + { + var directiveArgs = new List + { + new(SubgraphArg, subgraphName), + new(SelectArg, select.ToString(false)) + }; + + if (arguments is { Count: > 0 }) + { + var argumentDefs = new List(); + + foreach (var argumentDef in arguments) + { + argumentDefs.Add( + new ObjectValueNode( + new ObjectFieldNode( + NameArg, + argumentDef.Key), + new ObjectFieldNode( + TypeArg, + argumentDef.Value.ToString(false)))); + } + + directiveArgs.Add(new Argument(ArgumentsArg, new ListValueNode(argumentDefs))); + } + + if(kind != EntityResolverKind.Single) + { + var kindValue = kind switch + { + EntityResolverKind.Batch => FusionEnumValueNames.Batch, + EntityResolverKind.BatchWithKey => FusionEnumValueNames.BatchByKey, + _ => throw new NotSupportedException() + }; + + directiveArgs.Add(new Argument(KindArg, kindValue)); + } + + return new Directive(Resolver, directiveArgs); + } private DirectiveType RegisterResolverDirectiveType( string name, ScalarType typeName, - ScalarType selectionSet) + InputObjectType argumentDef, + ScalarType selectionSet, + EnumType resolverKind) { var directiveType = new DirectiveType(name); directiveType.Arguments.Add(new InputField(SelectArg, new NonNullType(selectionSet))); directiveType.Arguments.Add(new InputField(SubgraphArg, new NonNullType(typeName))); + directiveType.Arguments.Add( + new InputField(ArgumentsArg, new ListType(new NonNullType(argumentDef)))); + directiveType.Arguments.Add(new InputField(KindArg, resolverKind)); directiveType.Locations |= DirectiveLocation.Object; directiveType.ContextData.Add(WellKnownContextData.IsFusionType, true); _fusionGraph.DirectiveTypes.Add(directiveType); diff --git a/src/HotChocolate/Fusion/src/Composition/Pipeline/ApplyRenameDirectiveMiddleware.cs b/src/HotChocolate/Fusion/src/Composition/Pipeline/ApplyRenameDirectiveMiddleware.cs index 50a65987509..d4ed6e9a860 100644 --- a/src/HotChocolate/Fusion/src/Composition/Pipeline/ApplyRenameDirectiveMiddleware.cs +++ b/src/HotChocolate/Fusion/src/Composition/Pipeline/ApplyRenameDirectiveMiddleware.cs @@ -17,7 +17,7 @@ public async ValueTask InvokeAsync(CompositionContext context, MergeDelegate nex if (schema.TryGetMember(directive.Coordinate, out IHasName? member) && member is IHasContextData memberWithContext) { - memberWithContext.ContextData["originalName"] = member.Name; + memberWithContext.ContextData[WellKnownContextData.OriginalName] = member.Name; } if (!schema.RenameMember(directive.Coordinate, directive.NewName)) diff --git a/src/HotChocolate/Fusion/src/Composition/Pipeline/Enrichers/RefResolverEntityEnricher.cs b/src/HotChocolate/Fusion/src/Composition/Pipeline/Enrichers/RefResolverEntityEnricher.cs index 5323f1bcdf0..2ed3cdd3240 100644 --- a/src/HotChocolate/Fusion/src/Composition/Pipeline/Enrichers/RefResolverEntityEnricher.cs +++ b/src/HotChocolate/Fusion/src/Composition/Pipeline/Enrichers/RefResolverEntityEnricher.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using HotChocolate.Language; using HotChocolate.Skimmed; @@ -46,7 +47,11 @@ entityResolverField.Type.Kind is TypeKind.NonNull && var selectionSet = new SelectionSetNode(new[] { selection }); // Create a new EntityResolver for the entity - var resolver = new EntityResolver(selectionSet, type.Name, schema.Name); + var resolver = new EntityResolver( + EntityResolverKind.Single, + selectionSet, + type.Name, + schema.Name); // Loop through each argument and create a new ArgumentNode // and VariableNode for the @ref directive argument @@ -61,10 +66,103 @@ entityResolverField.Type.Kind is TypeKind.NonNull && // Add the new EntityResolver to the entity metadata entity.Metadata.EntityResolvers.Add(resolver); } + + // Check if the query field can be used to infer a batch by key resolver. + if (IsListOf(entityResolverField.Type, type) && + entityResolverField.Arguments.Count == 1) + { + var argument = entityResolverField.Arguments.First(); + + if (argument.ContainsIsDirective() && IsListOfScalar(argument.Type)) + { + var arguments = new List(); + + // Create a new FieldNode for the entity resolver + var selection = new FieldNode( + null, + new NameNode(entityResolverField.GetOriginalName()), + null, + null, + Array.Empty(), + arguments, + null); + + // Create a new SelectionSetNode for the entity resolver + var selectionSet = new SelectionSetNode(new[] { selection }); + + // Create a new EntityResolver for the entity + var resolver = new EntityResolver( + EntityResolverKind.BatchWithKey, + selectionSet, + type.Name, + schema.Name); + + // Loop through each argument and create a new ArgumentNode + // and VariableNode for the @ref directive argument + foreach (var arg in entityResolverField.Arguments) + { + var directive = arg.GetIsDirective(); + var var = type.CreateVariableName(directive); + arguments.Add(new ArgumentNode(arg.Name, new VariableNode(var))); + resolver.Variables.Add( + var, + arg.CreateVariableField(directive, var)); + } + + // Add the new EntityResolver to the entity metadata + entity.Metadata.EntityResolvers.Add(resolver); + } + } } } } return default; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsListOf(IType type, IType entityType) + { + if (type.Kind == TypeKind.NonNull) + { + type = type.InnerType(); + } + + if (type.Kind != TypeKind.List) + { + return false; + } + + type = type.InnerType(); + + if (type.Kind == TypeKind.NonNull) + { + type = type.InnerType(); + } + + return ReferenceEquals(type, entityType); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsListOfScalar(IType type) + { + if (type.Kind == TypeKind.NonNull) + { + type = type.InnerType(); + } + + if (type.Kind != TypeKind.List) + { + return false; + } + + type = type.InnerType(); + + if (type.Kind == TypeKind.NonNull) + { + type = type.InnerType(); + } + + return type.Kind == TypeKind.Scalar; + } } diff --git a/src/HotChocolate/Fusion/src/Composition/Pipeline/MergeEntityMiddleware.cs b/src/HotChocolate/Fusion/src/Composition/Pipeline/MergeEntityMiddleware.cs index 17e7a76f39f..a1ea1f82424 100644 --- a/src/HotChocolate/Fusion/src/Composition/Pipeline/MergeEntityMiddleware.cs +++ b/src/HotChocolate/Fusion/src/Composition/Pipeline/MergeEntityMiddleware.cs @@ -1,3 +1,4 @@ +using HotChocolate.Language; using HotChocolate.Skimmed; using HotChocolate.Utilities; @@ -59,14 +60,12 @@ public static void Merge(this CompositionContext context, EntityPart source, Obj context.ApplySource(sourceField, source.Schema, targetField); - foreach (var argument in targetField.Arguments) { targetField.Directives.Add( CreateVariableDirective( context, argument.Name, - argument.Type, source.Schema.Name)); } } @@ -77,44 +76,52 @@ public static void ApplyResolvers( ObjectType entityType, EntityMetadata metadata) { + var variables = new HashSet<(string, string)>(); + foreach (var resolver in metadata.EntityResolvers) { - entityType.Directives.Add( - CreateResolverDirective( - context, - resolver)); - foreach (var variable in resolver.Variables) { - entityType.Directives.Add( - CreateVariableDirective( - context, - variable, - resolver.Subgraph)); + if (variables.Add((variable.Key, resolver.SubgraphName))) + { + entityType.Directives.Add( + CreateVariableDirective( + context, + variable, + resolver.SubgraphName)); + } } } - } - public static void ApplyVariable( - this CompositionContext context, - OutputField field, - InputField argument, - string subgraphName) - { - field.Directives.Add( - CreateVariableDirective( - context, - argument.Name, - argument.Type, - subgraphName)); + foreach (var resolver in metadata.EntityResolvers) + { + Dictionary? arguments = null; + + foreach (var variable in resolver.Variables) + { + arguments ??= new Dictionary(); + arguments.Add(variable.Key, variable.Value.Definition.Type); + } + + entityType.Directives.Add( + CreateResolverDirective( + context, + resolver, + arguments, + resolver.Kind)); + } } private static Directive CreateResolverDirective( CompositionContext context, - EntityResolver resolver) + EntityResolver resolver, + Dictionary? arguments = null, + EntityResolverKind kind = EntityResolverKind.Single) => context.FusionTypes.CreateResolverDirective( - resolver.Subgraph, - resolver.SelectionSet); + resolver.SubgraphName, + resolver.SelectionSet, + arguments, + kind); private static Directive CreateVariableDirective( CompositionContext context, @@ -123,17 +130,14 @@ private static Directive CreateVariableDirective( => context.FusionTypes.CreateVariableDirective( schemaName, variable.Key, - variable.Value.Field, - variable.Value.Definition.Type); + variable.Value.Field); private static Directive CreateVariableDirective( CompositionContext context, string variableName, - IType argumentType, string subgraphName) => context.FusionTypes.CreateVariableDirective( subgraphName, variableName, - variableName, - argumentType.ToTypeNode()); + variableName); } diff --git a/src/HotChocolate/Fusion/src/Composition/Pipeline/MergeQueryTypeMiddleware.cs b/src/HotChocolate/Fusion/src/Composition/Pipeline/MergeQueryTypeMiddleware.cs index 2b361ddf80f..1641555f35a 100644 --- a/src/HotChocolate/Fusion/src/Composition/Pipeline/MergeQueryTypeMiddleware.cs +++ b/src/HotChocolate/Fusion/src/Composition/Pipeline/MergeQueryTypeMiddleware.cs @@ -1,5 +1,4 @@ using HotChocolate.Language; - using HotChocolate.Skimmed; namespace HotChocolate.Fusion.Composition.Pipeline; @@ -69,13 +68,22 @@ public static void ApplyResolvers( this CompositionContext context, OutputField field, SelectionSetNode selectionSet, - string schemaName) + string subgraphName) { + Dictionary? arguments = null; + + foreach (var argument in field.Arguments) + { + arguments ??= new Dictionary(); + arguments.Add(argument.Name, argument.Type.ToTypeNode()); + } + field.Directives.Add( CreateResolverDirective( context, selectionSet, - schemaName)); + subgraphName, + arguments)); } public static void ApplyVariable( @@ -88,24 +96,25 @@ public static void ApplyVariable( CreateVariableDirective( context, argument.Name, - argument.Type, subgraphName)); } private static Directive CreateResolverDirective( CompositionContext context, SelectionSetNode selectionSet, - string subgraphName) - => context.FusionTypes.CreateResolverDirective(subgraphName, selectionSet); + string subgraphName, + Dictionary? arguments = null) + => context.FusionTypes.CreateResolverDirective( + subgraphName, + selectionSet, + arguments); private static Directive CreateVariableDirective( CompositionContext context, string variableName, - IType argumentType, string subgraphName) => context.FusionTypes.CreateVariableDirective( subgraphName, variableName, - variableName, - argumentType.ToTypeNode()); + variableName); } diff --git a/src/HotChocolate/Fusion/src/Composition/Pipeline/RemoveFusionTypesMiddleware.cs b/src/HotChocolate/Fusion/src/Composition/Pipeline/RemoveFusionTypesMiddleware.cs index e6f14961229..6ea14c91a5b 100644 --- a/src/HotChocolate/Fusion/src/Composition/Pipeline/RemoveFusionTypesMiddleware.cs +++ b/src/HotChocolate/Fusion/src/Composition/Pipeline/RemoveFusionTypesMiddleware.cs @@ -14,6 +14,8 @@ public ValueTask InvokeAsync(CompositionContext context, MergeDelegate next) context.FusionGraph.Types.Remove(context.FusionTypes.Selection); context.FusionGraph.Types.Remove(context.FusionTypes.SelectionSet); context.FusionGraph.Types.Remove(context.FusionTypes.Uri); + context.FusionGraph.Types.Remove(context.FusionTypes.ArgumentDefinition); + context.FusionGraph.Types.Remove(context.FusionTypes.ResolverKind); // Remove the fusion directives from the GraphQL schema context.FusionGraph.DirectiveTypes.Remove(context.FusionTypes.Resolver); diff --git a/src/HotChocolate/Fusion/src/Core/Clients/GraphQLClientFactory.cs b/src/HotChocolate/Fusion/src/Core/Clients/GraphQLClientFactory.cs index 1f4a922fce7..1b41d00ba13 100644 --- a/src/HotChocolate/Fusion/src/Core/Clients/GraphQLClientFactory.cs +++ b/src/HotChocolate/Fusion/src/Core/Clients/GraphQLClientFactory.cs @@ -2,13 +2,13 @@ namespace HotChocolate.Fusion.Clients; internal sealed class GraphQLClientFactory { - private readonly Dictionary _executors; + private readonly Dictionary> _clientFactories; - public GraphQLClientFactory(IEnumerable executors) + public GraphQLClientFactory(Dictionary> clientFactories) { - _executors = executors.ToDictionary(t => t.SubgraphName); + _clientFactories = clientFactories; } - public IGraphQLClient Create(string schemaName) - => _executors[schemaName]; + public IGraphQLClient Create(string subgraphName) + => _clientFactories[subgraphName](); } diff --git a/src/HotChocolate/Fusion/src/Core/Clients/GraphQLRequest.cs b/src/HotChocolate/Fusion/src/Core/Clients/GraphQLRequest.cs index 08e388327b0..bd679cf20fe 100644 --- a/src/HotChocolate/Fusion/src/Core/Clients/GraphQLRequest.cs +++ b/src/HotChocolate/Fusion/src/Core/Clients/GraphQLRequest.cs @@ -2,7 +2,7 @@ namespace HotChocolate.Fusion.Clients; -public readonly struct GraphQLRequest +public sealed class GraphQLRequest { public GraphQLRequest( string subgraph, diff --git a/src/HotChocolate/Fusion/src/Core/Clients/GraphQLResponse.cs b/src/HotChocolate/Fusion/src/Core/Clients/GraphQLResponse.cs index af080ae50a6..f9a6b18723d 100644 --- a/src/HotChocolate/Fusion/src/Core/Clients/GraphQLResponse.cs +++ b/src/HotChocolate/Fusion/src/Core/Clients/GraphQLResponse.cs @@ -1,31 +1,37 @@ using System.Text.Json; +using HotChocolate.Transport.Sockets.Client; namespace HotChocolate.Fusion.Clients; public sealed class GraphQLResponse : IDisposable { - private readonly JsonDocument? _document; + private readonly IDisposable? _resource; - public GraphQLResponse(JsonDocument? document) + internal GraphQLResponse(OperationResult result) { - _document = document; + _resource = result; + Data = result.Data ?? new(); + Errors = result.Errors ?? new(); + Extensions = result.Extensions ?? new(); + } + + public GraphQLResponse(JsonDocument document) + { + _resource = document; + + if (document.RootElement.TryGetProperty(ResponseProperties.Data, out var value)) + { + Data = value; + } - if (_document is not null) + if (document.RootElement.TryGetProperty(ResponseProperties.Errors, out value)) { - if (_document.RootElement.TryGetProperty(ResponseProperties.Data, out var value)) - { - Data = value; - } - - if (_document.RootElement.TryGetProperty(ResponseProperties.Errors, out value)) - { - Errors = value; - } - - if (_document.RootElement.TryGetProperty(ResponseProperties.Extensions, out value)) - { - Extensions = value; - } + Errors = value; + } + + if (document.RootElement.TryGetProperty(ResponseProperties.Extensions, out value)) + { + Extensions = value; } } @@ -36,7 +42,5 @@ public GraphQLResponse(JsonDocument? document) public JsonElement Extensions { get; } public void Dispose() - { - _document?.Dispose(); - } + => _resource?.Dispose(); } diff --git a/src/HotChocolate/Fusion/src/Core/Clients/GraphQLHttpClient.cs b/src/HotChocolate/Fusion/src/Core/Clients/HttpGraphQLClient.cs similarity index 55% rename from src/HotChocolate/Fusion/src/Core/Clients/GraphQLHttpClient.cs rename to src/HotChocolate/Fusion/src/Core/Clients/HttpGraphQLClient.cs index 5809d94d0bf..45069ad77b5 100644 --- a/src/HotChocolate/Fusion/src/Core/Clients/GraphQLHttpClient.cs +++ b/src/HotChocolate/Fusion/src/Core/Clients/HttpGraphQLClient.cs @@ -7,63 +7,70 @@ namespace HotChocolate.Fusion.Clients; -// note: should the GraphQL client handle the capabilities? -// meaning the execution engine should just use batching and -// all and the client decides to batch if batching is available? -public sealed class GraphQLHttpClient : IGraphQLClient +public sealed class HttpGraphQLClient : IGraphQLClient { + private const string _jsonMediaType = "application/json"; + private const string _graphqlMediaType = "application/graphql-response+json"; + private static readonly Encoding _utf8 = Encoding.UTF8; private readonly JsonRequestFormatter _formatter = new(); private readonly HttpClient _client; - public GraphQLHttpClient(string schemaName, IHttpClientFactory httpClientFactory) + public HttpGraphQLClient(string subgraph, HttpClient httpClient) { - SubgraphName = schemaName; - _client = httpClientFactory.CreateClient(SubgraphName); + SubgraphName = subgraph; + _client = httpClient; } - // TODO: naming? SubgraphName? public string SubgraphName { get; } - public async Task ExecuteAsync( + public Task ExecuteAsync( GraphQLRequest request, CancellationToken cancellationToken) { - // todo : this is just a naive dummy implementation + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return ExecuteInternalAsync(request, cancellationToken); + } + + private async Task ExecuteInternalAsync( + GraphQLRequest request, + CancellationToken ct) + { using var writer = new ArrayWriter(); using var requestMessage = CreateRequestMessage(writer, request); - using var responseMessage = await _client.SendAsync(requestMessage, cancellationToken); - - // responseMessage.EnsureSuccessStatusCode(); // TODO : remove for production + using var responseMessage = await _client.SendAsync(requestMessage, ct); await using var contentStream = await responseMessage.Content - .ReadAsStreamAsync(cancellationToken) + .ReadAsStreamAsync(ct) .ConfigureAwait(false); - var stream = contentStream; - var sourceEncoding = GetEncoding(responseMessage.Content.Headers.ContentType?.CharSet); + var stream = contentStream; + var contentType = responseMessage.Content.Headers.ContentType; + var sourceEncoding = GetEncoding(contentType?.CharSet); - if (sourceEncoding is not null && - !Equals(sourceEncoding.EncodingName, Encoding.UTF8.EncodingName)) + if (sourceEncoding is not null && !Equals(sourceEncoding.EncodingName, _utf8.EncodingName)) { stream = GetTranscodingStream(contentStream, sourceEncoding); } - var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); - return new GraphQLResponse(document); - } + if (contentType?.MediaType.EqualsOrdinal(_jsonMediaType) ?? false) + { + responseMessage.EnsureSuccessStatusCode(); + var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct); + return new GraphQLResponse(document); + } - public Task> ExecuteBatchAsync( - IReadOnlyList requests, - CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + if (contentType?.MediaType.EqualsOrdinal(_graphqlMediaType) ?? false) + { + var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct); + return new GraphQLResponse(document); + } - public Task> SubscribeAsync( - GraphQLRequest graphQLRequests, - CancellationToken cancellationToken) - { - throw new NotImplementedException(); + throw new InvalidContentTypeException( + FusionResources.GraphQLHttpClient_InvalidContentType); } private HttpRequestMessage CreateRequestMessage(ArrayWriter writer, GraphQLRequest request) @@ -72,7 +79,9 @@ private HttpRequestMessage CreateRequestMessage(ArrayWriter writer, GraphQLReque var requestMessage = new HttpRequestMessage(HttpMethod.Post, default(Uri)); requestMessage.Content = new ByteArrayContent(writer.GetInternalBuffer(), 0, writer.Length); - requestMessage.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + requestMessage.Content.Headers.ContentType = new MediaTypeHeaderValue(_jsonMediaType); + requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(_graphqlMediaType)); + requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(_jsonMediaType)); return requestMessage; } @@ -112,4 +121,7 @@ private static Stream GetTranscodingStream(Stream contentStream, Encoding source innerStreamEncoding: sourceEncoding, outerStreamEncoding: Encoding.UTF8); } + + public void Dispose() + => _client.Dispose(); } diff --git a/src/HotChocolate/Fusion/src/Core/Clients/IGraphQLClient.cs b/src/HotChocolate/Fusion/src/Core/Clients/IGraphQLClient.cs index 490644c240d..b14535a5af1 100644 --- a/src/HotChocolate/Fusion/src/Core/Clients/IGraphQLClient.cs +++ b/src/HotChocolate/Fusion/src/Core/Clients/IGraphQLClient.cs @@ -1,18 +1,28 @@ namespace HotChocolate.Fusion.Clients; -public interface IGraphQLClient +/// +/// Represents a client for making GraphQL requests to a subgraph. +/// +public interface IGraphQLClient : IDisposable { + /// + /// Gets the name of the subgraph that this client is connected to. + /// string SubgraphName { get; } + /// + /// Executes a single GraphQL request asynchronously and returns the response. + /// + /// + /// The GraphQL request to execute. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// A task representing the asynchronous operation, which returns the GraphQL response. + /// Task ExecuteAsync( GraphQLRequest request, CancellationToken cancellationToken); - - Task> ExecuteBatchAsync( - IReadOnlyList requests, - CancellationToken cancellationToken); - - Task> SubscribeAsync( - GraphQLRequest graphQLRequests, - CancellationToken cancellationToken); } diff --git a/src/HotChocolate/Fusion/src/Core/Clients/IGraphQLSubscriptionClient.cs b/src/HotChocolate/Fusion/src/Core/Clients/IGraphQLSubscriptionClient.cs new file mode 100644 index 00000000000..0a1520f6fde --- /dev/null +++ b/src/HotChocolate/Fusion/src/Core/Clients/IGraphQLSubscriptionClient.cs @@ -0,0 +1,28 @@ +namespace HotChocolate.Fusion.Clients; + +/// +/// Represents a client for subscribing to a GraphQL subgraph. +/// +public interface IGraphQLSubscriptionClient : IDisposable +{ + /// + /// Gets the name of the subgraph that this client is connected to. + /// + string SubgraphName { get; } + + /// + /// Subscribes to a GraphQL subscription asynchronously and returns a stream of responses. + /// + /// + /// The GraphQL subscription to subscribe to. + /// + /// + /// A cancellation token that can be used to cancel the operation. + /// + /// + /// A task representing the asynchronous operation, which returns a stream of GraphQL responses. + /// + ValueTask> SubscribeAsync( + GraphQLRequest request, + CancellationToken cancellationToken); +} diff --git a/src/HotChocolate/Fusion/src/Core/Clients/InvalidContentTypeException.cs b/src/HotChocolate/Fusion/src/Core/Clients/InvalidContentTypeException.cs new file mode 100644 index 00000000000..82cd687ea79 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Core/Clients/InvalidContentTypeException.cs @@ -0,0 +1,8 @@ +namespace HotChocolate.Fusion.Clients; + +public sealed class InvalidContentTypeException : Exception +{ + public InvalidContentTypeException(string message) : base(message) + { + } +} diff --git a/src/HotChocolate/Fusion/src/Core/Clients/WebSocketGraphQLSubscriptionClient.cs b/src/HotChocolate/Fusion/src/Core/Clients/WebSocketGraphQLSubscriptionClient.cs new file mode 100644 index 00000000000..2d400437993 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Core/Clients/WebSocketGraphQLSubscriptionClient.cs @@ -0,0 +1,54 @@ +using System.Net.WebSockets; +using System.Runtime.CompilerServices; +using HotChocolate.Transport.Sockets.Client; + +namespace HotChocolate.Fusion.Clients; + +internal sealed class WebSocketGraphQLSubscriptionClient : IGraphQLSubscriptionClient +{ + private readonly Func _webSocketFactory; + + public WebSocketGraphQLSubscriptionClient( + string subgraphName, + Func webSocketFactory) + { + SubgraphName = subgraphName; + _webSocketFactory = webSocketFactory; + } + + public string SubgraphName { get; } + + public ValueTask> SubscribeAsync( + GraphQLRequest request, + CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return new(SubscribeInternalAsync(request, _webSocketFactory(), cancellationToken)); + } + + private static async IAsyncEnumerable SubscribeInternalAsync( + GraphQLRequest request, + WebSocket webSocket, + [EnumeratorCancellation] CancellationToken ct) + { + var operationRequest = new OperationRequest( + query: request.Document.ToString(false), + id: null, + variables: request.VariableValues, + extensions: request.Extensions); + + var client = await SocketClient.ConnectAsync(webSocket, ct).ConfigureAwait(false); + using var socketResult = await client.ExecuteAsync(operationRequest, ct); + + await foreach (var operationResult in socketResult.ReadResultsAsync().WithCancellation(ct)) + { + yield return new GraphQLResponse(operationResult); + } + } + + public void Dispose() { } +} diff --git a/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs b/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs index 69b5c0b5f0e..2f0acb78668 100644 --- a/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs @@ -66,19 +66,29 @@ public static IRequestExecutorBuilder AddFusionGatewayServer( .ConfigureSchemaServices( sc => { - foreach (var schemaName in configuration.SubgraphNames) - { - sc.AddSingleton( - sp => new GraphQLHttpClient( - schemaName, - sp.GetApplicationService())); - } + sc.AddSingleton( + sp => + { + var clientFactory = sp.GetApplicationService(); + var map = new Dictionary>(); + + IGraphQLClient Create(string subgraphName) + => new HttpGraphQLClient( + subgraphName, + clientFactory.CreateClient(subgraphName)); + + foreach (var subgraphName in configuration.SubgraphNames) + { + map.Add(subgraphName, () => Create(subgraphName)); + } + + return new GraphQLClientFactory(map); + }); sc.TryAddSingleton(configuration); sc.TryAddSingleton(); sc.TryAddSingleton(); sc.TryAddSingleton(); - sc.TryAddSingleton(); sc.TryAddSingleton(); }); } diff --git a/src/HotChocolate/Fusion/src/Core/Execution/FederatedQueryContext.cs b/src/HotChocolate/Fusion/src/Core/Execution/FederatedQueryContext.cs index 3f03cf45e0b..3664d57dcd8 100644 --- a/src/HotChocolate/Fusion/src/Core/Execution/FederatedQueryContext.cs +++ b/src/HotChocolate/Fusion/src/Core/Execution/FederatedQueryContext.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using HotChocolate.Execution.Processing; using HotChocolate.Fusion.Clients; using HotChocolate.Fusion.Metadata; @@ -33,22 +32,21 @@ public FederatedQueryContext( public OperationContext OperationContext { get; } - public ConcurrentQueue Work { get; } = new(); - - public Dictionary> WorkByNode { get; } = new(); - - public HashSet Completed { get; } = new(); - public bool NeedsMoreData(ISelectionSet selectionSet) => QueryPlan.HasNodes(selectionSet); - // TODO : implement batching here + public async Task ExecuteAsync(string subgraphName, GraphQLRequest request, CancellationToken cancellationToken) + { + using var client = _clientFactory.Create(subgraphName); + return await client.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + } + public async Task> ExecuteAsync( - string schemaName, + string subgraphName, IReadOnlyList requests, CancellationToken cancellationToken) { - var client = _clientFactory.Create(schemaName); + using var client = _clientFactory.Create(subgraphName); var responses = new GraphQLResponse[requests.Count]; for (var i = 0; i < requests.Count; i++) diff --git a/src/HotChocolate/Fusion/src/Core/Execution/IFederationContext.cs b/src/HotChocolate/Fusion/src/Core/Execution/IFederationContext.cs index 89031e15d0f..3c255908ac6 100644 --- a/src/HotChocolate/Fusion/src/Core/Execution/IFederationContext.cs +++ b/src/HotChocolate/Fusion/src/Core/Execution/IFederationContext.cs @@ -23,8 +23,13 @@ internal interface IFederationContext bool NeedsMoreData(ISelectionSet selectionSet); + Task ExecuteAsync( + string subgraphName, + GraphQLRequest request, + CancellationToken cancellationToken); + Task> ExecuteAsync( - string schemaName, + string subgraphName, IReadOnlyList requests, CancellationToken cancellationToken); } diff --git a/src/HotChocolate/Fusion/src/Core/Execution/WorkItem.cs b/src/HotChocolate/Fusion/src/Core/Execution/WorkItem.cs index faf2a77cb9e..776eee866e4 100644 --- a/src/HotChocolate/Fusion/src/Core/Execution/WorkItem.cs +++ b/src/HotChocolate/Fusion/src/Core/Execution/WorkItem.cs @@ -3,11 +3,11 @@ namespace HotChocolate.Fusion.Execution; -internal struct WorkItem +internal readonly struct WorkItem { public WorkItem( ISelectionSet selectionSet, - ObjectResult result, + ObjectResult result, IReadOnlyList exportKeys) { SelectionSet = selectionSet; diff --git a/src/HotChocolate/Fusion/src/Core/FusionResources.Designer.cs b/src/HotChocolate/Fusion/src/Core/FusionResources.Designer.cs index 236716d3135..70261529aad 100644 --- a/src/HotChocolate/Fusion/src/Core/FusionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Core/FusionResources.Designer.cs @@ -86,5 +86,11 @@ internal static string ThrowHelper_ServiceConfInvalidDirectiveArgs { return ResourceManager.GetString("ThrowHelper_ServiceConfInvalidDirectiveArgs", resourceCulture); } } + + internal static string GraphQLHttpClient_InvalidContentType { + get { + return ResourceManager.GetString("GraphQLHttpClient_InvalidContentType", resourceCulture); + } + } } } diff --git a/src/HotChocolate/Fusion/src/Core/FusionResources.resx b/src/HotChocolate/Fusion/src/Core/FusionResources.resx index 94b0543ebb6..49865bdbea1 100644 --- a/src/HotChocolate/Fusion/src/Core/FusionResources.resx +++ b/src/HotChocolate/Fusion/src/Core/FusionResources.resx @@ -39,4 +39,7 @@ Expected arguments for the directive `{0}` are `{1}`. The service configuration reader found the following arguments `{2}` on the directive in line number {3}. + + The GraphQL server returned an invalid content type. The content type must be either application/json or application/graphql-response+json. + diff --git a/src/HotChocolate/Fusion/src/Core/HotChocolate.Fusion.csproj b/src/HotChocolate/Fusion/src/Core/HotChocolate.Fusion.csproj index ded27f00470..645732d10ed 100644 --- a/src/HotChocolate/Fusion/src/Core/HotChocolate.Fusion.csproj +++ b/src/HotChocolate/Fusion/src/Core/HotChocolate.Fusion.csproj @@ -14,6 +14,7 @@ + @@ -36,6 +37,12 @@ True FusionResources.resx + + ResolverDefinition.cs + + + ResolverDefinition.cs + diff --git a/src/HotChocolate/Fusion/src/Core/Metadata/FieldVariableDefinition.cs b/src/HotChocolate/Fusion/src/Core/Metadata/FieldVariableDefinition.cs index 3af14ffb779..51575d5ab68 100644 --- a/src/HotChocolate/Fusion/src/Core/Metadata/FieldVariableDefinition.cs +++ b/src/HotChocolate/Fusion/src/Core/Metadata/FieldVariableDefinition.cs @@ -7,12 +7,10 @@ internal sealed class FieldVariableDefinition : IVariableDefinition public FieldVariableDefinition( string name, string subgraph, - ITypeNode type, FieldNode select) { Name = name; Subgraph = subgraph; - Type = type; Select = select; } @@ -20,7 +18,5 @@ public FieldVariableDefinition( public string Subgraph { get; } - public ITypeNode Type { get; } - public FieldNode Select { get; } } diff --git a/src/HotChocolate/Fusion/src/Core/Metadata/FusionGraphConfigurationReader.cs b/src/HotChocolate/Fusion/src/Core/Metadata/FusionGraphConfigurationReader.cs index e90229604d9..09377480302 100644 --- a/src/HotChocolate/Fusion/src/Core/Metadata/FusionGraphConfigurationReader.cs +++ b/src/HotChocolate/Fusion/src/Core/Metadata/FusionGraphConfigurationReader.cs @@ -10,6 +10,7 @@ namespace HotChocolate.Fusion.Metadata; internal sealed class FusionGraphConfigurationReader { + private readonly Dictionary _emptyArgumentDefs = new(); private readonly HashSet _assert = new(); public FusionGraphConfiguration Read(string sourceText) @@ -230,11 +231,10 @@ private FieldVariableDefinition ReadFieldVariableDefinition( DirectiveNode directiveNode) { AssertName(directiveNode, typeNames.VariableDirective); - AssertArguments(directiveNode, NameArg, SelectArg, TypeArg, SubgraphArg); + AssertArguments(directiveNode, NameArg, SelectArg, SubgraphArg); string name = default!; FieldNode select = default!; - ITypeNode type = default!; string schemaName = default!; foreach (var argument in directiveNode.Arguments) @@ -249,17 +249,13 @@ private FieldVariableDefinition ReadFieldVariableDefinition( select = ParseField(Expect(argument.Value).Value); break; - case TypeArg: - type = ParseTypeReference(Expect(argument.Value).Value); - break; - case SubgraphArg: schemaName = Expect(argument.Value).Value; break; } } - return new FieldVariableDefinition(name, schemaName, type, select); + return new FieldVariableDefinition(name, schemaName, select); } private ResolverDefinitionCollection ReadResolverDefinitions( @@ -286,10 +282,12 @@ private ResolverDefinition ReadResolverDefinition( DirectiveNode directiveNode) { AssertName(directiveNode, typeNames.ResolverDirective); - AssertArguments(directiveNode, SelectArg, SubgraphArg); + AssertArguments(directiveNode, OptionalArgs, SelectArg, SubgraphArg); SelectionSetNode select = default!; string subgraph = default!; + var kind = ResolverKind.Query; + Dictionary? arguments = null; foreach (var argument in directiveNode.Arguments) { @@ -302,6 +300,20 @@ private ResolverDefinition ReadResolverDefinition( case SubgraphArg: subgraph = Expect(argument.Value).Value; break; + + case KindArg: + kind = Expect(argument.Value).Value switch + { + FusionEnumValueNames.Query => ResolverKind.Query, + FusionEnumValueNames.Batch => ResolverKind.Batch, + FusionEnumValueNames.BatchByKey => ResolverKind.BatchByKey, + _ => throw new InvalidOperationException() + }; + break; + + case ArgumentsArg: + arguments = ReadResolverArgumentDefinitions(argument.Value); + break; } } @@ -330,11 +342,58 @@ private ResolverDefinition ReadResolverDefinition( return new ResolverDefinition( subgraph, + kind, select, placeholder, _assert.Count == 0 ? Array.Empty() - : _assert.ToArray()); + : _assert.ToArray(), + arguments ?? _emptyArgumentDefs); + + static void OptionalArgs(HashSet assert) + { + assert.Remove(KindArg); + assert.Remove(ArgumentsArg); + } + } + + private Dictionary? ReadResolverArgumentDefinitions( + IValueNode argumentDefinitions) + { + if (argumentDefinitions is NullValueNode) + { + return null; + } + + var arguments = new Dictionary(); + + foreach (var argumentDef in Expect(argumentDefinitions).Items) + { + var argumentDefNode = Expect(argumentDef); + + string argumentName = default!; + ITypeNode argumentType = default!; + + foreach (var argumentDefArgument in argumentDefNode.Fields) + { + switch (argumentDefArgument.Name.Value) + { + case NameArg: + argumentName = Expect(argumentDefArgument.Value).Value; + break; + + case TypeArg: + argumentType = ParseTypeReference( + Expect(argumentDefArgument.Value).Value); + break; + } + } + + arguments.Add(argumentName, argumentType); + } + + return arguments; + } private MemberBindingCollection ReadMemberBindings( @@ -443,6 +502,12 @@ private static void AssertName(DirectiveNode directive, string expectedName) } private void AssertArguments(DirectiveNode directive, params string[] expectedArguments) + => AssertArguments(directive, null, expectedArguments); + + private void AssertArguments( + DirectiveNode directive, + Action>? beforeAssert, + params string[] expectedArguments) { if (directive.Arguments.Count == 0) { @@ -457,6 +522,7 @@ private void AssertArguments(DirectiveNode directive, params string[] expectedAr } _assert.ExceptWith(expectedArguments); + beforeAssert?.Invoke(_assert); if (_assert.Count > 0) { diff --git a/src/HotChocolate/Fusion/src/Core/Metadata/IVariableDefinition.cs b/src/HotChocolate/Fusion/src/Core/Metadata/IVariableDefinition.cs index 3010ad7051b..ae25fbeab18 100644 --- a/src/HotChocolate/Fusion/src/Core/Metadata/IVariableDefinition.cs +++ b/src/HotChocolate/Fusion/src/Core/Metadata/IVariableDefinition.cs @@ -16,9 +16,4 @@ internal interface IVariableDefinition /// Gets the name of the subgraph the variable is defined for. /// string Subgraph { get; } - - /// - /// Gets the type the variable must have for the subgraph. - /// - ITypeNode Type { get; } } diff --git a/src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.FetchRewriterContext.cs b/src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.FetchRewriterContext.cs new file mode 100644 index 00000000000..4ef523b791b --- /dev/null +++ b/src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.FetchRewriterContext.cs @@ -0,0 +1,36 @@ +using HotChocolate.Language; +using HotChocolate.Language.Visitors; + +namespace HotChocolate.Fusion.Metadata; + +internal sealed partial class ResolverDefinition +{ + private sealed class FetchRewriterContext : ISyntaxVisitorContext + { + public FetchRewriterContext( + FragmentSpreadNode? placeholder, + IReadOnlyDictionary variables, + SelectionSetNode? selectionSet, + string? responseName) + { + Placeholder = placeholder; + Variables = variables; + SelectionSet = selectionSet; + ResponseName = responseName; + } + + public string? ResponseName { get; } + + public Stack Path { get; } = new(); + + public FragmentSpreadNode? Placeholder { get; } + + public bool PlaceholderFound { get; set; } + + public IReadOnlyDictionary Variables { get; } + + public SelectionSetNode? SelectionSet { get; } + + public IReadOnlyList SelectionPath { get; set; } = Array.Empty(); + } +} diff --git a/src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.ResolverRewriter.cs b/src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.ResolverRewriter.cs new file mode 100644 index 00000000000..e9e03f907e5 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.ResolverRewriter.cs @@ -0,0 +1,115 @@ +using HotChocolate.Language; +using HotChocolate.Language.Visitors; +using HotChocolate.Utilities; + +namespace HotChocolate.Fusion.Metadata; + +internal sealed partial class ResolverDefinition +{ + private class ResolverRewriter : SyntaxRewriter + { + protected override FieldNode? RewriteField(FieldNode node, FetchRewriterContext context) + { + var result = base.RewriteField(node, context); + + if (result is not null && context.PlaceholderFound) + { + context.PlaceholderFound = false; + + if (context.ResponseName is not null && + !node.Name.Value.EqualsOrdinal(context.ResponseName)) + { + return result.WithAlias(new NameNode(context.ResponseName)); + } + } + + return result; + } + + protected override SelectionSetNode? RewriteSelectionSet( + SelectionSetNode node, + FetchRewriterContext context) + { + var rewritten = base.RewriteSelectionSet(node, context); + + if (rewritten is not null && context.SelectionSet is not null) + { + List? rewrittenList = null; + for (var i = 0; i < rewritten.Selections.Count; i++) + { + var selectionNode = rewritten.Selections[i]; + + if (rewrittenList is null) + { + if (!selectionNode.Equals(context.Placeholder, SyntaxComparison.Syntax)) + { + continue; + } + + // preserve selection path, so we are later able to unwrap the result. + var path = context.Path.ToArray(); + context.SelectionPath = path; + context.PlaceholderFound = true; + rewrittenList = new List(); + + if (context.ResponseName is not null) + { + path[^1] = context.ResponseName; + } + + for (var j = 0; j < i; j++) + { + rewrittenList.Add(rewritten.Selections[j]); + } + } + + foreach (var selection in context.SelectionSet.Selections) + { + rewrittenList.Add(selection); + } + } + + return rewrittenList is null + ? rewritten + : rewritten.WithSelections(rewrittenList); + } + + return rewritten; + } + + protected override ISyntaxNode? OnRewrite(ISyntaxNode node, FetchRewriterContext context) + { + if (node is VariableNode variableNode && + context.Variables.TryGetValue(variableNode.Name.Value, out var valueNode)) + { + return valueNode; + } + + return base.OnRewrite(node, context); + } + + protected override FetchRewriterContext OnEnter( + ISyntaxNode node, + FetchRewriterContext context) + { + if (node is FieldNode field) + { + context.Path.Push(field.Name.Value); + } + + return base.OnEnter(node, context); + } + + protected override void OnLeave( + ISyntaxNode? node, + FetchRewriterContext context) + { + if (node is FieldNode) + { + context.Path.Pop(); + } + + base.OnLeave(node, context); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.cs b/src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.cs index 7bbe4510511..24ff407a51a 100644 --- a/src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.cs +++ b/src/HotChocolate/Fusion/src/Core/Metadata/ResolverDefinition.cs @@ -1,25 +1,26 @@ -using HotChocolate.Execution.Processing; using HotChocolate.Language; -using HotChocolate.Language.Visitors; -using HotChocolate.Utilities; namespace HotChocolate.Fusion.Metadata; -internal sealed class ResolverDefinition +internal sealed partial class ResolverDefinition { private static readonly ResolverRewriter _rewriter = new(); private readonly FieldNode? _field; public ResolverDefinition( string subgraphName, + ResolverKind kind, SelectionSetNode select, FragmentSpreadNode? placeholder, - IReadOnlyList requires) + IReadOnlyList requires, + IReadOnlyDictionary arguments) { SubgraphName = subgraphName; + Kind = kind; Select = select; Placeholder = placeholder; Requires = requires; + Arguments = arguments; if (select.Selections is [FieldNode field]) { @@ -32,12 +33,16 @@ public ResolverDefinition( /// public string SubgraphName { get; } + public ResolverKind Kind { get; } + public SelectionSetNode Select { get; } public FragmentSpreadNode? Placeholder { get; } public IReadOnlyList Requires { get; } + public IReadOnlyDictionary Arguments { get; } + public (ISelectionNode selectionNode, IReadOnlyList Path) CreateSelection( IReadOnlyDictionary variables, SelectionSetNode? selectionSet, @@ -59,140 +64,4 @@ public ResolverDefinition( return ((ISelectionNode)selection!, context.SelectionPath); } - - private class ResolverRewriter : SyntaxRewriter - { - protected override FieldNode? RewriteField(FieldNode node, FetchRewriterContext context) - { - var result = base.RewriteField(node, context); - - if (result is not null && context.PlaceholderFound) - { - context.PlaceholderFound = false; - - if (context.ResponseName is not null && - !node.Name.Value.EqualsOrdinal(context.ResponseName)) - { - return result.WithAlias(new NameNode(context.ResponseName)); - } - } - - return result; - } - - protected override SelectionSetNode? RewriteSelectionSet( - SelectionSetNode node, - FetchRewriterContext context) - { - var rewritten = base.RewriteSelectionSet(node, context); - - if (rewritten is not null && context.SelectionSet is not null) - { - List? rewrittenList = null; - for (var i = 0; i < rewritten.Selections.Count; i++) - { - var selectionNode = rewritten.Selections[i]; - - if (rewrittenList is null) - { - if (!selectionNode.Equals(context.Placeholder, SyntaxComparison.Syntax)) - { - continue; - } - - // preserve selection path, so we are later able to unwrap the result. - var path = context.Path.ToArray(); - context.SelectionPath = path; - context.PlaceholderFound = true; - rewrittenList = new List(); - - if (context.ResponseName is not null) - { - path[^1] = context.ResponseName; - } - - for (var j = 0; j < i; j++) - { - rewrittenList.Add(rewritten.Selections[j]); - } - } - - foreach (var selection in context.SelectionSet.Selections) - { - rewrittenList.Add(selection); - } - } - - return rewrittenList is null - ? rewritten - : rewritten.WithSelections(rewrittenList); - } - - return rewritten; - } - - protected override ISyntaxNode? OnRewrite(ISyntaxNode node, FetchRewriterContext context) - { - if (node is VariableNode variableNode && - context.Variables.TryGetValue(variableNode.Name.Value, out var valueNode)) - { - return valueNode; - } - - return base.OnRewrite(node, context); - } - - protected override FetchRewriterContext OnEnter( - ISyntaxNode node, - FetchRewriterContext context) - { - if (node is FieldNode field) - { - context.Path.Push(field.Name.Value); - } - - return base.OnEnter(node, context); - } - - protected override void OnLeave( - ISyntaxNode? node, - FetchRewriterContext context) - { - if (node is FieldNode) - { - context.Path.Pop(); - } - - base.OnLeave(node, context); - } - } - - private sealed class FetchRewriterContext : ISyntaxVisitorContext - { - public FetchRewriterContext( - FragmentSpreadNode? placeholder, - IReadOnlyDictionary variables, - SelectionSetNode? selectionSet, - string? responseName) - { - Placeholder = placeholder; - Variables = variables; - SelectionSet = selectionSet; - ResponseName = responseName; - } - - public string? ResponseName { get; } - - public Stack Path { get; } = new(); - - public FragmentSpreadNode? Placeholder { get; } - - public bool PlaceholderFound { get; set; } - - public IReadOnlyDictionary Variables { get; } - - public SelectionSetNode? SelectionSet { get; } - - public IReadOnlyList SelectionPath { get; set; } = Array.Empty(); - } } diff --git a/src/HotChocolate/Fusion/src/Core/Metadata/ResolverKind.cs b/src/HotChocolate/Fusion/src/Core/Metadata/ResolverKind.cs new file mode 100644 index 00000000000..c378e749b8c --- /dev/null +++ b/src/HotChocolate/Fusion/src/Core/Metadata/ResolverKind.cs @@ -0,0 +1,9 @@ +namespace HotChocolate.Fusion.Metadata; + +internal enum ResolverKind +{ + Query, + Batch, + BatchByKey, + Subscription, +} diff --git a/src/HotChocolate/Fusion/src/Core/Planning/ExecutionPlanBuilder.cs b/src/HotChocolate/Fusion/src/Core/Planning/ExecutionPlanBuilder.cs index 791236e2fd2..72506f980d1 100644 --- a/src/HotChocolate/Fusion/src/Core/Planning/ExecutionPlanBuilder.cs +++ b/src/HotChocolate/Fusion/src/Core/Planning/ExecutionPlanBuilder.cs @@ -1,8 +1,8 @@ +using System.Diagnostics; using HotChocolate.Execution.Processing; using HotChocolate.Fusion.Metadata; using HotChocolate.Language; using HotChocolate.Utilities; -using Microsoft.AspNetCore.Server.IIS.Core; namespace HotChocolate.Fusion.Planning; @@ -21,9 +21,14 @@ public QueryPlan Build(QueryPlanContext context) { foreach (var step in context.Steps) { - if (step is SelectionExecutionStep selectionStep) + if (step is SelectionExecutionStep { Resolver.Kind: ResolverKind.BatchByKey } batchStep) { - var fetchNode = CreateFetchNode(context, selectionStep); + var fetchNode = CreateBatchResolverNode(context, batchStep, batchStep.Resolver); + context.Nodes.Add(batchStep, fetchNode); + } + else if (step is SelectionExecutionStep selectionStep) + { + var fetchNode = CreateResolverNode(context, selectionStep); context.Nodes.Add(selectionStep, fetchNode); } else if (step is IntrospectionExecutionStep) @@ -90,7 +95,7 @@ private QueryPlanNode BuildQueryTree(QueryPlanContext context) return parent; } - private ResolverNode CreateFetchNode( + private ResolverNode CreateResolverNode( QueryPlanContext context, SelectionExecutionStep executionStep) { @@ -108,6 +113,50 @@ private ResolverNode CreateFetchNode( path); } + private BatchByKeyResolverNode CreateBatchResolverNode( + QueryPlanContext context, + SelectionExecutionStep executionStep, + ResolverDefinition resolver) + { + var selectionSet = ResolveSelectionSet(context, executionStep); + var (requestDocument, path) = CreateRequestDocument(context, executionStep); + + context.HasNodes.Add(selectionSet); + + var argumentTypes = resolver.Arguments; + + if (argumentTypes.Count > 0) + { + var temp = new Dictionary(); + + foreach (var argument in resolver.Arguments) + { + if (!context.Exports.TryGetStateKey( + executionStep.RootSelections[0].Selection.DeclaringSelectionSet, + argument.Key, + out var stateKey, + out _)) + { + // TODO : Exception + throw new InvalidOperationException("The state is inconsistent."); + } + + temp.Add(stateKey, argument.Value); + } + + argumentTypes = temp; + } + + return new BatchByKeyResolverNode( + context.CreateNodeId(), + executionStep.SubgraphName, + requestDocument, + selectionSet, + executionStep.Variables.Values.ToArray(), + path, + argumentTypes); + } + private ISelectionSet ResolveSelectionSet( QueryPlanContext context, IExecutionStep executionStep) @@ -146,7 +195,9 @@ private ISelectionSet ResolveSelectionSet( null, context.CreateRemoteOperationName(), OperationType.Query, - context.Exports.CreateVariableDefinitions(executionStep.Variables.Values), + context.Exports.CreateVariableDefinitions( + executionStep.Variables.Values, + executionStep.Resolver?.Arguments), Array.Empty(), rootSelectionSetNode); @@ -160,6 +211,7 @@ private SelectionSetNode CreateRootSelectionSetNode( var selectionNodes = new List(); var selectionSet = executionStep.RootSelections[0].Selection.DeclaringSelectionSet; var selectionSetType = executionStep.SelectionSetType; + Debug.Assert(selectionSet is not null); // create foreach (var rootSelection in executionStep.RootSelections) diff --git a/src/HotChocolate/Fusion/src/Core/Planning/ExportDefinitionRegistry.cs b/src/HotChocolate/Fusion/src/Core/Planning/ExportDefinitionRegistry.cs index f10c85297b3..1b63b4d6748 100644 --- a/src/HotChocolate/Fusion/src/Core/Planning/ExportDefinitionRegistry.cs +++ b/src/HotChocolate/Fusion/src/Core/Planning/ExportDefinitionRegistry.cs @@ -8,27 +8,43 @@ namespace HotChocolate.Fusion.Planning; internal sealed class ExportDefinitionRegistry { private readonly Dictionary<(ISelectionSet, string), string> _stateKeyLookup = new(); - private readonly Dictionary _exportDefinitions = new(StringComparer.Ordinal); + private readonly Dictionary _exportLookup = new(StringComparer.Ordinal); + private readonly List _exports = new(); private readonly string _groupKey = "_fusion_exports_"; private int _stateId; - public IReadOnlyCollection All => _exportDefinitions.Values; + public IReadOnlyCollection All => _exportLookup.Values; public string Register( ISelectionSet selectionSet, FieldVariableDefinition variableDefinition, - IExecutionStep executionStep) + IExecutionStep providingExecutionStep) { var exportDefinition = new ExportDefinition( $"_{_groupKey}_{++_stateId}", selectionSet, variableDefinition, - executionStep); - _exportDefinitions.Add(exportDefinition.StateKey, exportDefinition); + providingExecutionStep); + _exportLookup.Add(exportDefinition.StateKey, exportDefinition); _stateKeyLookup.Add((selectionSet, variableDefinition.Name), exportDefinition.StateKey); + _exports.Add(exportDefinition); return exportDefinition.StateKey; } + public void RegisterAdditionExport( + FieldVariableDefinition variableDefinition, + IExecutionStep providingExecutionStep, + string stateKey) + { + var originalExport = _exportLookup[stateKey]; + var exportDefinition = new ExportDefinition( + stateKey, + originalExport.SelectionSet, + variableDefinition, + providingExecutionStep); + _exports.Add(exportDefinition); + } + public bool TryGetStateKey( ISelectionSet selectionSet, string variableName, @@ -37,7 +53,7 @@ public bool TryGetStateKey( { if (_stateKeyLookup.TryGetValue((selectionSet, variableName), out stateKey)) { - executionStep = _exportDefinitions[stateKey].ExecutionStep; + executionStep = _exportLookup[stateKey].ExecutionStep; return true; } @@ -47,9 +63,10 @@ public bool TryGetStateKey( } public IReadOnlyList CreateVariableDefinitions( - IReadOnlyCollection stateKeys) + IReadOnlyCollection stateKeys, + IReadOnlyDictionary? argumentTypes) { - if (stateKeys.Count == 0) + if (stateKeys.Count == 0 || argumentTypes is null) { return Array.Empty(); } @@ -59,11 +76,11 @@ public IReadOnlyList CreateVariableDefinitions( foreach (var stateKey in stateKeys) { - var variableDefinition = _exportDefinitions[stateKey].VariableDefinition; + var variableDefinition = _exportLookup[stateKey].VariableDefinition; definitions[index++] = new VariableDefinitionNode( null, new VariableNode(stateKey), - variableDefinition.Type, + argumentTypes[variableDefinition.Name], null, Array.Empty()); } @@ -75,7 +92,7 @@ public IEnumerable GetExportSelections( IExecutionStep executionStep, ISelectionSet selectionSet) { - foreach (var exportDefinition in _exportDefinitions.Values) + foreach (var exportDefinition in _exports) { if (ReferenceEquals(exportDefinition.ExecutionStep, executionStep) && ReferenceEquals(exportDefinition.SelectionSet, selectionSet)) diff --git a/src/HotChocolate/Fusion/src/Core/Planning/Nodes/BatchByKeyResolverNode.cs b/src/HotChocolate/Fusion/src/Core/Planning/Nodes/BatchByKeyResolverNode.cs new file mode 100644 index 00000000000..adbfa38f854 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Core/Planning/Nodes/BatchByKeyResolverNode.cs @@ -0,0 +1,366 @@ +using System.Text.Json; +using HotChocolate.Execution.Processing; +using HotChocolate.Fusion.Clients; +using HotChocolate.Fusion.Execution; +using HotChocolate.Language; +using static HotChocolate.Fusion.Execution.ExecutorUtils; +using GraphQLRequest = HotChocolate.Fusion.Clients.GraphQLRequest; + +namespace HotChocolate.Fusion.Planning; + +internal sealed class BatchByKeyResolverNode : QueryPlanNode +{ + private readonly IReadOnlyList _path; + + public BatchByKeyResolverNode( + int id, + string subgraphName, + DocumentNode document, + ISelectionSet selectionSet, + IReadOnlyList requires, + IReadOnlyList path, + IReadOnlyDictionary argumentTypes) + : base(id) + { + SubgraphName = subgraphName; + Document = document; + SelectionSet = selectionSet; + Requires = requires; + ArgumentTypes = argumentTypes; + _path = path; + } + + public override QueryPlanNodeKind Kind => QueryPlanNodeKind.BatchResolver; + + /// + /// Gets the schema name on which this request handler executes. + /// + public string SubgraphName { get; } + + /// + /// Gets the GraphQL request document. + /// + public DocumentNode Document { get; } + + /// + /// Gets the selection set for which this request provides a patch. + /// + public ISelectionSet SelectionSet { get; } + + /// + /// Gets the variables that this request handler requires to create a request. + /// + public IReadOnlyList Requires { get; } + + /// + /// Gets the type lookup of resolver arguments. + /// + /// + public IReadOnlyDictionary ArgumentTypes { get; } + + protected override async Task OnExecuteAsync( + IFederationContext context, + IExecutionState state, + CancellationToken cancellationToken) + { + if (state.TryGetState(SelectionSet, out var originalWorkItems)) + { + for (var i = 0; i < originalWorkItems.Count; i++) + { + ExtractPartialResult(originalWorkItems[i]); + } + + var workItems = CreateBatchWorkItem(originalWorkItems); + var subgraphName = SubgraphName; + var firstWorkItem = workItems[0]; + var selections = firstWorkItem.SelectionSet.Selections; + + // Create the batch subgraph request. + var variableValues = BuildVariables(workItems); + var request = CreateRequest(variableValues); + + // Once we have the batch request, we will enqueue it for execution with + // the execution engine. + var response = await context.ExecuteAsync( + subgraphName, + request, + cancellationToken) + .ConfigureAwait(false); + + // Before we extract the data from the responses we will enqueue the responses + // for cleanup so that the memory can be released at the end of the execution. + context.Result.RegisterForCleanup( + response, + r => + { + r.Dispose(); + return default!; + }); + + var result = UnwrapResult(response, firstWorkItem.ExportKeys); + + for (var i = 0; i < workItems.Length; i++) + { + var workItem = workItems[i]; + if (result.TryGetValue(workItem.Key, out var workItemData)) + { + ExtractSelectionResults(selections, subgraphName, workItemData, workItem.SelectionResults); + ExtractVariables(workItemData, firstWorkItem.ExportKeys, workItem.VariableValues); + } + } + + } + } + + protected override async Task OnExecuteNodesAsync( + IFederationContext context, + IExecutionState state, + CancellationToken cancellationToken) + { + if (state.ContainsState(SelectionSet)) + { + await base.OnExecuteNodesAsync(context, state, cancellationToken).ConfigureAwait(false); + } + } + + private GraphQLRequest CreateRequest(IReadOnlyDictionary variableValues) + { + ObjectValueNode? vars = null; + + if (Requires.Count > 0) + { + var fields = new List(); + + foreach (var requirement in Requires) + { + if (variableValues.TryGetValue(requirement, out var value)) + { + fields.Add(new ObjectFieldNode(requirement, value)); + } + else + { + // TODO : error helper + throw new ArgumentException( + $"The variable value `{requirement}` was not provided " + + "but is required.", + nameof(variableValues)); + } + } + + vars ??= new ObjectValueNode(fields); + } + + return new GraphQLRequest(SubgraphName, Document, vars, null); + } + + private Dictionary BuildVariables(BatchWorkItem[] workItems) + { + if (workItems.Length == 1) + { + return workItems[0].VariableValues; + } + + var variableValues = new Dictionary(); + var uniqueWorkItems = new List(); + var processed = new HashSet(); + + foreach (var workItem in workItems) + { + if (processed.Add(workItem.Key)) + { + uniqueWorkItems.Add(workItem); + } + } + + foreach (var key in workItems[0].VariableValues.Keys) + { + var expectedType = ArgumentTypes[key]; + + if (expectedType.IsListType()) + { + var list = new List(); + + foreach (var value in uniqueWorkItems) + { + if (value.VariableValues.TryGetValue(key, out var variableValue)) + { + list.Add(variableValue); + } + } + + variableValues.Add(key, new ListValueNode(list)); + } + else + { + if (workItems[0].VariableValues.TryGetValue(key, out var variableValue)) + { + variableValues.Add(key, variableValue); + } + } + } + + return variableValues; + } + + private Dictionary UnwrapResult( + GraphQLResponse response, + IReadOnlyList exportKeys) + { + var data = response.Data; + + if (_path.Count > 0) + { + data = LiftData(); + } + + var result = new Dictionary(); + + if (exportKeys.Count == 1) + { + var key = exportKeys[0]; + foreach (var element in data.EnumerateArray()) + { + if (element.TryGetProperty(key, out var keyValue)) + { + result.TryAdd(keyValue.ToString(), element); + } + } + } + else + { + foreach (var element in data.EnumerateArray()) + { + var key = string.Empty; + + foreach (var exportKey in exportKeys) + { + if (element.TryGetProperty(exportKey, out var keyValue)) + { + key += keyValue.ToString(); + } + } + + result.TryAdd(key, element); + } + } + + return result; + + JsonElement LiftData() + { + if (data.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null) + { + var current = data; + + for (var i = 0; i < _path.Count; i++) + { + if (current.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return current; + } + + current.TryGetProperty(_path[i], out var propertyValue); + current = propertyValue; + } + + return current; + } + + return data; + } + } + + protected override void FormatProperties(Utf8JsonWriter writer) + { + writer.WriteString("schemaName", SubgraphName); + writer.WriteString("document", Document.ToString(false)); + writer.WriteNumber("selectionSetId", SelectionSet.Id); + + if (_path.Count > 0) + { + writer.WritePropertyName("path"); + writer.WriteStartArray(); + + foreach (var path in _path) + { + writer.WriteStringValue(path); + } + writer.WriteEndArray(); + } + + if (Requires.Count > 0) + { + writer.WritePropertyName("requires"); + writer.WriteStartArray(); + + foreach (var requirement in Requires) + { + writer.WriteStartObject(); + writer.WriteString("variable", requirement); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + } + + private static BatchWorkItem[] CreateBatchWorkItem(IReadOnlyList workItems) + { + var exportKeys = workItems[0].ExportKeys; + var batchWorkItems = new BatchWorkItem[workItems.Count]; + + if (exportKeys.Count == 1) + { + for (var i = 0; i < workItems.Count; i++) + { + var workItem = workItems[i]; + var key = workItem.VariableValues.First().Value.ToString(); + batchWorkItems[i] = new BatchWorkItem(key, workItem); + } + } + else + { + for (var i = 0; i < workItems.Count; i++) + { + var workItem = workItems[i]; + var key = string.Empty; + + for (var j = 0; j < exportKeys.Count; j++) + { + var value = workItem.VariableValues[exportKeys[j]].ToString(); + key += value; + } + + batchWorkItems[i] = new BatchWorkItem(key, workItem); + } + } + + return batchWorkItems; + } + + private readonly struct BatchWorkItem + { + public BatchWorkItem( + string batchKey, + WorkItem workItem) + { + Key = batchKey; + VariableValues = workItem.VariableValues; + ExportKeys = workItem.ExportKeys; + SelectionSet = workItem.SelectionSet; + SelectionResults = workItem.SelectionResults; + Result = workItem.Result; + } + + public string Key { get; } + + public Dictionary VariableValues { get; } + + public IReadOnlyList ExportKeys { get; } + + public ISelectionSet SelectionSet { get; } + + public SelectionResult[] SelectionResults { get; } + + public ObjectResult Result { get; } + } +} diff --git a/src/HotChocolate/Fusion/src/Core/Planning/Nodes/QueryPlanNodeKind.cs b/src/HotChocolate/Fusion/src/Core/Planning/Nodes/QueryPlanNodeKind.cs index 84801964d78..adf057c4e3c 100644 --- a/src/HotChocolate/Fusion/src/Core/Planning/Nodes/QueryPlanNodeKind.cs +++ b/src/HotChocolate/Fusion/src/Core/Planning/Nodes/QueryPlanNodeKind.cs @@ -5,6 +5,8 @@ internal enum QueryPlanNodeKind Parallel, Serial, Resolver, + BatchResolver, + Subscription, Introspection, Composition, } diff --git a/src/HotChocolate/Fusion/src/Core/Planning/Nodes/ResolverNode.cs b/src/HotChocolate/Fusion/src/Core/Planning/Nodes/ResolverNode.cs index 69bdea3a31c..2dc58d3aca8 100644 --- a/src/HotChocolate/Fusion/src/Core/Planning/Nodes/ResolverNode.cs +++ b/src/HotChocolate/Fusion/src/Core/Planning/Nodes/ResolverNode.cs @@ -55,18 +55,18 @@ protected override async Task OnExecuteAsync( IExecutionState state, CancellationToken cancellationToken) { - if (state.TryGetState(SelectionSet, out var values)) + if (state.TryGetState(SelectionSet, out var workItems)) { var schemaName = SubgraphName; - var requests = new GraphQLRequest[values.Count]; - var selections = values[0].SelectionSet.Selections; + var requests = new GraphQLRequest[workItems.Count]; + var selections = workItems[0].SelectionSet.Selections; // first we will create a request for all of our work items. - for (var i = 0; i < values.Count; i++) + for (var i = 0; i < workItems.Count; i++) { - var value = values[i]; - ExtractPartialResult(value); - requests[i] = CreateRequest(value.VariableValues); + var workItem = workItems[i]; + ExtractPartialResult(workItem); + requests[i] = CreateRequest(workItem.VariableValues); } // once we have the requests, we will enqueue them for execution with the execution engine. @@ -96,7 +96,7 @@ protected override async Task OnExecuteAsync( { var response = responses[i]; var data = UnwrapResult(response); - var workItem = values[i]; + var workItem = workItems[i]; var selectionResults = workItem.SelectionResults; var exportKeys = workItem.ExportKeys; var variableValues = workItem.VariableValues; diff --git a/src/HotChocolate/Fusion/src/Core/Planning/RequestPlanner.cs b/src/HotChocolate/Fusion/src/Core/Planning/RequestPlanner.cs index 6ecc24273b1..ed6f51687c7 100644 --- a/src/HotChocolate/Fusion/src/Core/Planning/RequestPlanner.cs +++ b/src/HotChocolate/Fusion/src/Core/Planning/RequestPlanner.cs @@ -26,11 +26,23 @@ public void Plan(QueryPlanContext context) var selections = context.Operation.RootSelectionSet.Selections; var backlog = new Queue(); - Plan(context, backlog, selectionSetType, selections, null); - - while (backlog .TryDequeue(out var item)) + Plan( + context, + backlog, + selectionSetType, + selections, + null, + false); + + while (backlog.TryDequeue(out var item)) { - Plan(context, backlog, item.DeclaringType, item.Selections, item.ParentSelection); + Plan( + context, + backlog, + item.DeclaringType, + item.Selections, + item.ParentSelection, + item.PreferBatching); } } @@ -39,7 +51,8 @@ private void Plan( Queue backlog, ObjectType selectionSetType, IReadOnlyList selections, - ISelection? parentSelection) + ISelection? parentSelection, + bool preferBatching) { var variablesInContext = new HashSet(); List? leftovers = null; @@ -47,7 +60,7 @@ private void Plan( do { var current = (IReadOnlyList?)leftovers ?? selections; - var subgraph = ResolveBestMatchingSubgraph(context.Operation, current, selectionSetType); + var subgraph = GetBestMatchingSubgraph(context.Operation, current, selectionSetType); var workItem = new SelectionExecutionStep(subgraph, selectionSetType, parentSelection); leftovers = null; ResolverDefinition? resolver; @@ -56,7 +69,12 @@ private void Plan( selectionSetType.Resolvers.ContainsResolvers(subgraph)) { CalculateVariablesInContext(selectionSetType, parentSelection, variablesInContext); - if (TryGetResolver(selectionSetType, subgraph, variablesInContext, out resolver)) + if (TryGetResolver( + selectionSetType, + subgraph, + variablesInContext, + preferBatching, + out resolver)) { workItem.Resolver = resolver; CalculateRequirements(parentSelection, resolver, workItem.Requires); @@ -94,7 +112,12 @@ private void Plan( resolver = null; if (field.Resolvers.ContainsResolvers(subgraph)) { - if (!TryGetResolver(field, subgraph, variablesInContext, out resolver)) + if (!TryGetResolver( + field, + subgraph, + variablesInContext, + preferBatching, + out resolver)) { // todo : error message and type throw new InvalidOperationException( @@ -114,7 +137,12 @@ private void Plan( if (selection.SelectionSet is not null) { - CollectChildSelections(backlog, context.Operation, selection, workItem); + CollectChildSelections( + backlog, + context.Operation, + selection, + workItem, + preferBatching); } } else @@ -135,8 +163,14 @@ private void CollectChildSelections( Queue backlog, IOperation operation, ISelection parentSelection, - SelectionExecutionStep executionStep) + SelectionExecutionStep executionStep, + bool preferBatching) { + if (!preferBatching) + { + preferBatching = Types.TypeExtensions.IsListType(parentSelection.Type); + } + foreach (var possibleType in operation.GetPossibleTypes(parentSelection)) { var declaringType = _configuration.GetType(possibleType.Name); @@ -155,7 +189,12 @@ private void CollectChildSelections( if (selection.SelectionSet is not null) { - CollectChildSelections(backlog, operation, selection, executionStep); + CollectChildSelections( + backlog, + operation, + selection, + executionStep, + preferBatching); } } else @@ -166,12 +205,17 @@ private void CollectChildSelections( if (leftovers is not null) { - backlog.Enqueue(new BacklogItem(parentSelection, declaringType, leftovers)); + backlog.Enqueue( + new BacklogItem( + parentSelection, + declaringType, + leftovers, + preferBatching)); } } } - private string ResolveBestMatchingSubgraph( + private string GetBestMatchingSubgraph( IOperation operation, IReadOnlyList selections, ObjectType typeContext) @@ -231,42 +275,38 @@ private static bool TryGetResolver( ObjectField field, string schemaName, HashSet variablesInContext, + bool preferBatching, [NotNullWhen(true)] out ResolverDefinition? resolver) - { - if (field.Resolvers.TryGetValue(schemaName, out var resolvers)) - { - foreach (var current in resolvers) - { - var canBeUsed = true; - - foreach (var requirement in current.Requires) - { - if (!variablesInContext.Contains(requirement)) - { - canBeUsed = false; - break; - } - } - - if (canBeUsed) - { - resolver = current; - return true; - } - } - } - - resolver = null; - return false; - } + => TryGetResolver( + field.Resolvers, + schemaName, + variablesInContext, + preferBatching, + out resolver); private static bool TryGetResolver( ObjectType declaringType, string schemaName, HashSet variablesInContext, + bool preferBatching, + [NotNullWhen(true)] out ResolverDefinition? resolver) + => TryGetResolver( + declaringType.Resolvers, + schemaName, + variablesInContext, + preferBatching, + out resolver); + + private static bool TryGetResolver( + ResolverDefinitionCollection resolverDefinitions, + string schemaName, + HashSet variablesInContext, + bool preferBatching, [NotNullWhen(true)] out ResolverDefinition? resolver) { - if (declaringType.Resolvers.TryGetValue(schemaName, out var resolvers)) + resolver = null; + + if (resolverDefinitions.TryGetValue(schemaName, out var resolvers)) { foreach (var current in resolvers) { @@ -283,14 +323,39 @@ private static bool TryGetResolver( if (canBeUsed) { - resolver = current; - return true; + switch (current.Kind) + { + case ResolverKind.Batch: + resolver = current; + if (preferBatching) + { + return true; + } + break; + + case ResolverKind.BatchByKey: + resolver = current; + break; + + case ResolverKind.Subscription: + throw new NotImplementedException(); + + case ResolverKind.Query: + default: + if (!preferBatching) + { + resolver = current; + return true; + } + + resolver ??= current; + break; + } } } } - resolver = null; - return false; + return resolver is not null; } private void CalculateVariablesInContext( @@ -389,11 +454,13 @@ private readonly struct BacklogItem public BacklogItem( ISelection parentSelection, ObjectType declaringType, - IReadOnlyList selections) + IReadOnlyList selections, + bool preferBatching) { ParentSelection = parentSelection; DeclaringType = declaringType; Selections = selections; + PreferBatching = preferBatching; } public ISelection ParentSelection { get; } @@ -401,5 +468,7 @@ public BacklogItem( public ObjectType DeclaringType { get; } public IReadOnlyList Selections { get; } + + public bool PreferBatching { get; } } } diff --git a/src/HotChocolate/Fusion/src/Core/Planning/RequirementsPlanner.cs b/src/HotChocolate/Fusion/src/Core/Planning/RequirementsPlanner.cs index 9dd5c45a1e2..56b4efe0533 100644 --- a/src/HotChocolate/Fusion/src/Core/Planning/RequirementsPlanner.cs +++ b/src/HotChocolate/Fusion/src/Core/Planning/RequirementsPlanner.cs @@ -1,4 +1,6 @@ using HotChocolate.Execution.Processing; +using HotChocolate.Fusion.Metadata; +using HotChocolate.Utilities; using static System.StringComparer; namespace HotChocolate.Fusion.Planning; @@ -54,11 +56,12 @@ executionStep.ParentSelection is { } parent && } } - // if we still have requirements unfulfilled will try to resolve them + // if we still have requirements unfulfilled, we will try to resolve them // from sibling execution steps. foreach (var variable in step.SelectionSetType.Variables) { var schemaName = variable.Subgraph; + if (requires.Contains(variable.Name) && schemas.TryGetValue(schemaName, out var providingExecutionStep)) { @@ -87,12 +90,27 @@ executionStep.ParentSelection is { } parent && // TODO : NEEDS A PROPER EXCEPTION throw new Exception("NEEDS A PROPER EXCEPTION"); } + + // if we do by key batching the current execution step must + // re-export its requirements. + if (executionStep.Resolver.Kind is ResolverKind.BatchByKey) + { + foreach (var variable in step.SelectionSetType.Variables) + { + if (executionStep.Requires.Contains(variable.Name) && + executionStep.SubgraphName.EqualsOrdinal(variable.Subgraph) && + context.Exports.TryGetStateKey(selectionSet, variable.Name, out var stateKey, out _)) + { + context.Exports.RegisterAdditionExport(variable, executionStep, stateKey); + } + } + } } } } private static HashSet GetSiblingExecutionSteps( - Dictionary selectionLookup, + Dictionary selectionLookup, ISelectionSet selectionSet) { var executionSteps = new HashSet(); diff --git a/src/HotChocolate/Fusion/src/Core/Planning/SelectionSetInfo.cs b/src/HotChocolate/Fusion/src/Core/Planning/SelectionSetInfo.cs deleted file mode 100644 index 36cede4ac8d..00000000000 --- a/src/HotChocolate/Fusion/src/Core/Planning/SelectionSetInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace HotChocolate.Fusion.Planning; - -/// -/// Provides information about the executable nodes that are associated a selection-set and -/// the state that is created by doing so. -/// -internal readonly struct SelectionSetInfo -{ - public SelectionSetInfo(int nodeCount, IReadOnlyList exportKeys) - { - NodeCount = nodeCount; - ExportKeys = exportKeys; - } - - /// - /// Gets the number of nodes that need to be executed for the specified selection-set. - /// - public int NodeCount { get; } - - /// - /// Gets the state that the execution of the nodes produce for this selection-set. - /// - public IReadOnlyList ExportKeys { get; } -} diff --git a/src/HotChocolate/Fusion/test/Composition.Tests/DemoIntegrationTests.cs b/src/HotChocolate/Fusion/test/Composition.Tests/DemoIntegrationTests.cs index 3996f390faa..90f2177baf4 100644 --- a/src/HotChocolate/Fusion/test/Composition.Tests/DemoIntegrationTests.cs +++ b/src/HotChocolate/Fusion/test/Composition.Tests/DemoIntegrationTests.cs @@ -1,4 +1,5 @@ using CookieCrumble; +using HotChocolate.Fusion.Shared; using HotChocolate.Skimmed.Serialization; namespace HotChocolate.Fusion.Composition; @@ -8,21 +9,16 @@ public sealed class DemoIntegrationTests [Fact] public async Task Accounts_And_Reviews() { + // arrange + using var demoProject = await DemoProject.CreateAsync(); + var composer = new FusionGraphComposer(); var fusionConfig = await composer.ComposeAsync( new[] { - new SubgraphConfiguration( - "Accounts", - AccountsSdl, - AccountsExtensionSdl, - new HttpClientConfiguration(new Uri("http://localhost:5000"))), - new SubgraphConfiguration( - "Reviews", - ReviewsSdl, - ReviewsExtensionSdl, - new HttpClientConfiguration(new Uri("http://localhost:5001"))) + demoProject.Accounts.ToConfiguration(AccountsExtensionSdl), + demoProject.Reviews.ToConfiguration(ReviewsExtensionSdl), }); SchemaFormatter @@ -30,74 +26,30 @@ public async Task Accounts_And_Reviews() .MatchSnapshot(extension: ".graphql"); } - private const string AccountsSdl = - """ - schema { - query: Query - } - - type Query { - users: [User!]! - userById(id: Int!): User! - } - - type User { - id: Int! - name: String! - birthdate: DateTime! - username: String! - } - - scalar DateTime - """; - private const string AccountsExtensionSdl = """ extend type Query { userById(id: Int! @is(field: "id")): User! + usersById(ids: [Int!]! @is(field: "id")): [User!]! } """; - private const string ReviewsSdl = + private const string ReviewsExtensionSdl = """ - schema - @rename(coordinate: "Author", newName: "User") - @rename(coordinate: "Query.authorById", newName: "userById") { - query: Query - } - - type Query { - reviews: [Review!]! - authorById(id: Int!): Author - productById(upc: Int!): Product - } - - type Review { - id: Int! - author: Author! - upc: Product! - body: String! - } - - type Author { - id: Int! - reviews: [Review!]! + extend type Query { + authorById(id: Int! @is(field: "id")): Author + productById(upc: Int! @is(field: "upc")): Product } - type Product { - upc: Int! - reviews: [Review!]! + schema + @rename(coordinate: "Query.authorById", newName: "userById") + @rename(coordinate: "Author", newName: "User") { } - - directive @ref(coordinate: String, field: String) on FIELD_DEFINITION - directive @rename(coordinate: String! to: String!) on SCHEMA - directive @remove(coordinate: String!) on SCHEMA """; - private const string ReviewsExtensionSdl = + private const string ProductsExtensionSdl = """ extend type Query { - authorById(id: Int! @is(field: "id")): Author productById(upc: Int! @is(field: "upc")): Product } """; diff --git a/src/HotChocolate/Fusion/test/Composition.Tests/HotChocolate.Fusion.Composition.Tests.csproj b/src/HotChocolate/Fusion/test/Composition.Tests/HotChocolate.Fusion.Composition.Tests.csproj index 4db4f432870..bcdf9ee122a 100644 --- a/src/HotChocolate/Fusion/test/Composition.Tests/HotChocolate.Fusion.Composition.Tests.csproj +++ b/src/HotChocolate/Fusion/test/Composition.Tests/HotChocolate.Fusion.Composition.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/src/HotChocolate/Fusion/test/Composition.Tests/__snapshots__/DemoIntegrationTests.Accounts_And_Reviews.graphql b/src/HotChocolate/Fusion/test/Composition.Tests/__snapshots__/DemoIntegrationTests.Accounts_And_Reviews.graphql index f670858ce54..ec471621317 100644 --- a/src/HotChocolate/Fusion/test/Composition.Tests/__snapshots__/DemoIntegrationTests.Accounts_And_Reviews.graphql +++ b/src/HotChocolate/Fusion/test/Composition.Tests/__snapshots__/DemoIntegrationTests.Accounts_And_Reviews.graphql @@ -1,15 +1,16 @@ -schema @fusion(version: 1) @httpClient(subgraph: "Accounts", baseAddress: "http:\/\/localhost:5000\/") @httpClient(subgraph: "Reviews", baseAddress: "http:\/\/localhost:5001\/") { +schema @fusion(version: 1) @httpClient(subgraph: "Accounts", baseAddress: "http:\/\/localhost:5000\/graphql") @httpClient(subgraph: "Reviews", baseAddress: "http:\/\/localhost:5000\/graphql") { query: Query } type Query { - productById(upc: Int!): Product @variable(subgraph: "Reviews", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }") + productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) reviews: [Review!]! @resolver(subgraph: "Reviews", select: "{ reviews }") - userById(id: Int!): User! @variable(subgraph: "Accounts", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }") @variable(subgraph: "Reviews", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Reviews", select: "{ userById(id: $id) }") + userById(id: Int!): User @variable(subgraph: "Accounts", name: "id", argument: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) @variable(subgraph: "Reviews", name: "id", argument: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) users: [User!]! @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [Int!]!): [User!]! @variable(subgraph: "Accounts", name: "ids", argument: "ids") @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[Int!]!" } ]) } -type Product @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Reviews", name: "Product_upc", select: "upc", type: "Int!") { +type Product @variable(subgraph: "Reviews", name: "Product_upc", select: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) { reviews: [Review!]! @source(subgraph: "Reviews") upc: Int! @source(subgraph: "Reviews") } @@ -18,15 +19,16 @@ type Review { author: User! @source(subgraph: "Reviews") body: String! @source(subgraph: "Reviews") id: Int! @source(subgraph: "Reviews") - upc: Product! @source(subgraph: "Reviews") + product: Product! @source(subgraph: "Reviews") } -type User @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }") @variable(subgraph: "Accounts", name: "User_id", select: "id", type: "Int!") @resolver(subgraph: "Reviews", select: "{ userById(id: $User_id) }") @variable(subgraph: "Reviews", name: "User_id", select: "id", type: "Int!") { +type User @source(subgraph: "Reviews", name: "Author") @variable(subgraph: "Accounts", name: "User_id", select: "id") @variable(subgraph: "Reviews", name: "User_id", select: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[Int!]!" } ], kind: "BATCH_BY_KEY") @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) { birthdate: DateTime! @source(subgraph: "Accounts") id: Int! @source(subgraph: "Accounts") @source(subgraph: "Reviews") - name: String! @source(subgraph: "Accounts") + name: String! @source(subgraph: "Accounts") @source(subgraph: "Reviews") reviews: [Review!]! @source(subgraph: "Reviews") username: String! @source(subgraph: "Accounts") } -scalar DateTime +"The `DateTime` scalar represents an ISO-8601 compliant date time type." +scalar DateTime \ No newline at end of file diff --git a/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs b/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs index acad383f26d..2d680675962 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs +++ b/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs @@ -2,7 +2,7 @@ using HotChocolate.Execution; using HotChocolate.Fusion.Composition; using HotChocolate.Fusion.Planning; -using HotChocolate.Fusion.Schemas; +using HotChocolate.Fusion.Shared; using HotChocolate.Language; using HotChocolate.Skimmed.Serialization; using Microsoft.Extensions.DependencyInjection; @@ -147,6 +147,49 @@ query GetUser { } """); + // act + var result = await executor.ExecuteAsync( + QueryRequestBuilder + .New() + .SetQuery(request) + .Create()); + + // assert + var snapshot = new Snapshot(); + CollectSnapshotData(snapshot, request, result, fusionGraph); + await snapshot.MatchAsync(); + } + + [Fact] + public async Task Authors_And_Reviews_Batch_Requests() + { + // arrange + using var demoProject = await DemoProject.CreateAsync(); + + // act + var fusionGraph = await new FusionGraphComposer().ComposeAsync( + new[] + { + demoProject.Reviews.ToConfiguration(ReviewsExtensionSdl), + demoProject.Accounts.ToConfiguration(AccountsExtensionSdl) + }); + + var executor = await new ServiceCollection() + .AddSingleton(demoProject.HttpClientFactory) + .AddFusionGatewayServer(SchemaFormatter.FormatAsString(fusionGraph)) + .BuildRequestExecutorAsync(); + + var request = Parse( + """ + query GetUser { + reviews { + body + author { + birthdate + } + } + } + """); // act var result = await executor.ExecuteAsync( @@ -333,6 +376,7 @@ private static void CollectSnapshotData( """ extend type Query { userById(id: Int! @is(field: "id")): User! + usersById(ids: [Int!]! @is(field: "id")): [User!]! } """; diff --git a/src/HotChocolate/Fusion/test/Core.Tests/HotChocolate.Fusion.Tests.csproj b/src/HotChocolate/Fusion/test/Core.Tests/HotChocolate.Fusion.Tests.csproj index 37357dd2a7a..26b96e2be48 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/HotChocolate.Fusion.Tests.csproj +++ b/src/HotChocolate/Fusion/test/Core.Tests/HotChocolate.Fusion.Tests.csproj @@ -6,13 +6,8 @@ - - - - - - + @@ -24,9 +19,4 @@ - - - - - diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Accounts/AccountQuery.cs b/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Accounts/AccountQuery.cs deleted file mode 100644 index dc991cb7cf4..00000000000 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Accounts/AccountQuery.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace HotChocolate.Fusion.Schemas.Accounts; - -[GraphQLName("Query")] -public class AccountQuery -{ - public IEnumerable GetUsers([Service] UserRepository repository) => - repository.GetUsers(); - - public User GetUserById(int id, [Service] UserRepository repository) => - repository.GetUser(id); -} diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_AutoCompose.graphql b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_AutoCompose.graphql index 3196f6fbeb4..7908be8395d 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_AutoCompose.graphql +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_AutoCompose.graphql @@ -3,15 +3,15 @@ schema @fusion(version: 1) @httpClient(subgraph: "Reviews", baseAddress: "http:\ } type Query { - authorById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }") - productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }") @variable(subgraph: "Products", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Products", select: "{ productById(upc: $upc) }") + productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) @variable(subgraph: "Products", name: "upc", argument: "upc") @resolver(subgraph: "Products", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) reviews: [Review!]! @resolver(subgraph: "Reviews", select: "{ reviews }") - topProducts(first: Int!): [Product!]! @variable(subgraph: "Products", name: "first", argument: "first", type: "Int!") @resolver(subgraph: "Products", select: "{ topProducts(first: $first) }") - userById(id: Int!): User! @variable(subgraph: "Accounts", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }") + topProducts(first: Int!): [Product!]! @variable(subgraph: "Products", name: "first", argument: "first") @resolver(subgraph: "Products", select: "{ topProducts(first: $first) }", arguments: [ { name: "first", type: "Int!" } ]) + userById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) @variable(subgraph: "Accounts", name: "id", argument: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) users: [User!]! @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [Int!]!): [User!]! @variable(subgraph: "Accounts", name: "ids", argument: "ids") @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[Int!]!" } ]) } -type Product @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Reviews", name: "Product_upc", select: "upc", type: "Int!") @resolver(subgraph: "Products", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Products", name: "Product_upc", select: "upc", type: "Int!") { +type Product @variable(subgraph: "Reviews", name: "Product_upc", select: "upc") @variable(subgraph: "Products", name: "Product_upc", select: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) @resolver(subgraph: "Products", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) { name: String! @source(subgraph: "Products") price: Int! @source(subgraph: "Products") reviews: [Review!]! @source(subgraph: "Reviews") @@ -26,7 +26,7 @@ type Review { product: Product! @source(subgraph: "Reviews") } -type User @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }") @variable(subgraph: "Reviews", name: "User_id", select: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }") @variable(subgraph: "Accounts", name: "User_id", select: "id", type: "Int!") { +type User @source(subgraph: "Reviews", name: "Author") @variable(subgraph: "Reviews", name: "User_id", select: "id") @variable(subgraph: "Accounts", name: "User_id", select: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[Int!]!" } ], kind: "BATCH_BY_KEY") { birthdate: DateTime! @source(subgraph: "Accounts") id: Int! @source(subgraph: "Reviews") @source(subgraph: "Accounts") name: String! @source(subgraph: "Reviews") @source(subgraph: "Accounts") diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.snap b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.snap index 4c12fb08984..dbfa4c1f3c6 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.snap +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.snap @@ -355,42 +355,42 @@ Result "kind": "OBJECT", "fields": [ { - "name": "authorById", + "name": "productById", "type": { "name": null, "kind": "NON_NULL" } }, { - "name": "productById", + "name": "reviews", "type": { "name": null, "kind": "NON_NULL" } }, { - "name": "reviews", + "name": "topProducts", "type": { "name": null, "kind": "NON_NULL" } }, { - "name": "topProducts", + "name": "userById", "type": { "name": null, "kind": "NON_NULL" } }, { - "name": "userById", + "name": "users", "type": { "name": null, "kind": "NON_NULL" } }, { - "name": "users", + "name": "usersById", "type": { "name": null, "kind": "NON_NULL" @@ -547,15 +547,15 @@ schema @fusion(version: 1) @httpClient(subgraph: "Reviews", baseAddress: "http:\ } type Query { - authorById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }") - productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }") @variable(subgraph: "Products", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Products", select: "{ productById(upc: $upc) }") + productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) @variable(subgraph: "Products", name: "upc", argument: "upc") @resolver(subgraph: "Products", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) reviews: [Review!]! @resolver(subgraph: "Reviews", select: "{ reviews }") - topProducts(first: Int!): [Product!]! @variable(subgraph: "Products", name: "first", argument: "first", type: "Int!") @resolver(subgraph: "Products", select: "{ topProducts(first: $first) }") - userById(id: Int!): User! @variable(subgraph: "Accounts", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }") + topProducts(first: Int!): [Product!]! @variable(subgraph: "Products", name: "first", argument: "first") @resolver(subgraph: "Products", select: "{ topProducts(first: $first) }", arguments: [ { name: "first", type: "Int!" } ]) + userById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) @variable(subgraph: "Accounts", name: "id", argument: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) users: [User!]! @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [Int!]!): [User!]! @variable(subgraph: "Accounts", name: "ids", argument: "ids") @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[Int!]!" } ]) } -type Product @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Reviews", name: "Product_upc", select: "upc", type: "Int!") @resolver(subgraph: "Products", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Products", name: "Product_upc", select: "upc", type: "Int!") { +type Product @variable(subgraph: "Reviews", name: "Product_upc", select: "upc") @variable(subgraph: "Products", name: "Product_upc", select: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) @resolver(subgraph: "Products", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) { name: String! @source(subgraph: "Products") price: Int! @source(subgraph: "Products") reviews: [Review!]! @source(subgraph: "Reviews") @@ -570,7 +570,7 @@ type Review { product: Product! @source(subgraph: "Reviews") } -type User @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }") @variable(subgraph: "Reviews", name: "User_id", select: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }") @variable(subgraph: "Accounts", name: "User_id", select: "id", type: "Int!") { +type User @source(subgraph: "Reviews", name: "Author") @variable(subgraph: "Reviews", name: "User_id", select: "id") @variable(subgraph: "Accounts", name: "User_id", select: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[Int!]!" } ], kind: "BATCH_BY_KEY") { birthdate: DateTime! @source(subgraph: "Accounts") id: Int! @source(subgraph: "Reviews") @source(subgraph: "Accounts") name: String! @source(subgraph: "Reviews") @source(subgraph: "Accounts") diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Query_TopProducts.snap b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Query_TopProducts.snap index 637ab657d40..c1152e7ce06 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Query_TopProducts.snap +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Query_TopProducts.snap @@ -103,15 +103,15 @@ schema @fusion(version: 1) @httpClient(subgraph: "Reviews", baseAddress: "http:\ } type Query { - authorById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }") - productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }") @variable(subgraph: "Products", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Products", select: "{ productById(upc: $upc) }") + productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) @variable(subgraph: "Products", name: "upc", argument: "upc") @resolver(subgraph: "Products", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) reviews: [Review!]! @resolver(subgraph: "Reviews", select: "{ reviews }") - topProducts(first: Int!): [Product!]! @variable(subgraph: "Products", name: "first", argument: "first", type: "Int!") @resolver(subgraph: "Products", select: "{ topProducts(first: $first) }") - userById(id: Int!): User! @variable(subgraph: "Accounts", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }") + topProducts(first: Int!): [Product!]! @variable(subgraph: "Products", name: "first", argument: "first") @resolver(subgraph: "Products", select: "{ topProducts(first: $first) }", arguments: [ { name: "first", type: "Int!" } ]) + userById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) @variable(subgraph: "Accounts", name: "id", argument: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) users: [User!]! @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [Int!]!): [User!]! @variable(subgraph: "Accounts", name: "ids", argument: "ids") @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[Int!]!" } ]) } -type Product @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Reviews", name: "Product_upc", select: "upc", type: "Int!") @resolver(subgraph: "Products", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Products", name: "Product_upc", select: "upc", type: "Int!") { +type Product @variable(subgraph: "Reviews", name: "Product_upc", select: "upc") @variable(subgraph: "Products", name: "Product_upc", select: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) @resolver(subgraph: "Products", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) { name: String! @source(subgraph: "Products") price: Int! @source(subgraph: "Products") reviews: [Review!]! @source(subgraph: "Reviews") @@ -126,7 +126,7 @@ type Review { product: Product! @source(subgraph: "Reviews") } -type User @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }") @variable(subgraph: "Reviews", name: "User_id", select: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }") @variable(subgraph: "Accounts", name: "User_id", select: "id", type: "Int!") { +type User @source(subgraph: "Reviews", name: "Author") @variable(subgraph: "Reviews", name: "User_id", select: "id") @variable(subgraph: "Accounts", name: "User_id", select: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[Int!]!" } ], kind: "BATCH_BY_KEY") { birthdate: DateTime! @source(subgraph: "Accounts") id: Int! @source(subgraph: "Reviews") @source(subgraph: "Accounts") name: String! @source(subgraph: "Reviews") @source(subgraph: "Accounts") diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Query_TypeName.snap b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Query_TypeName.snap index cef05147164..ee85f23d6a9 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Query_TypeName.snap +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Query_TypeName.snap @@ -105,15 +105,15 @@ schema @fusion(version: 1) @httpClient(subgraph: "Reviews", baseAddress: "http:\ } type Query { - authorById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }") - productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }") @variable(subgraph: "Products", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Products", select: "{ productById(upc: $upc) }") + productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) @variable(subgraph: "Products", name: "upc", argument: "upc") @resolver(subgraph: "Products", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) reviews: [Review!]! @resolver(subgraph: "Reviews", select: "{ reviews }") - topProducts(first: Int!): [Product!]! @variable(subgraph: "Products", name: "first", argument: "first", type: "Int!") @resolver(subgraph: "Products", select: "{ topProducts(first: $first) }") - userById(id: Int!): User! @variable(subgraph: "Accounts", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }") + topProducts(first: Int!): [Product!]! @variable(subgraph: "Products", name: "first", argument: "first") @resolver(subgraph: "Products", select: "{ topProducts(first: $first) }", arguments: [ { name: "first", type: "Int!" } ]) + userById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) @variable(subgraph: "Accounts", name: "id", argument: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) users: [User!]! @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [Int!]!): [User!]! @variable(subgraph: "Accounts", name: "ids", argument: "ids") @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[Int!]!" } ]) } -type Product @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Reviews", name: "Product_upc", select: "upc", type: "Int!") @resolver(subgraph: "Products", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Products", name: "Product_upc", select: "upc", type: "Int!") { +type Product @variable(subgraph: "Reviews", name: "Product_upc", select: "upc") @variable(subgraph: "Products", name: "Product_upc", select: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) @resolver(subgraph: "Products", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) { name: String! @source(subgraph: "Products") price: Int! @source(subgraph: "Products") reviews: [Review!]! @source(subgraph: "Reviews") @@ -128,7 +128,7 @@ type Review { product: Product! @source(subgraph: "Reviews") } -type User @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }") @variable(subgraph: "Reviews", name: "User_id", select: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }") @variable(subgraph: "Accounts", name: "User_id", select: "id", type: "Int!") { +type User @source(subgraph: "Reviews", name: "Author") @variable(subgraph: "Reviews", name: "User_id", select: "id") @variable(subgraph: "Accounts", name: "User_id", select: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[Int!]!" } ], kind: "BATCH_BY_KEY") { birthdate: DateTime! @source(subgraph: "Accounts") id: Int! @source(subgraph: "Reviews") @source(subgraph: "Accounts") name: String! @source(subgraph: "Reviews") @source(subgraph: "Accounts") diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_AutoCompose.graphql b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_AutoCompose.graphql index 55635379d1b..8d6390c7197 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_AutoCompose.graphql +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_AutoCompose.graphql @@ -3,14 +3,14 @@ schema @fusion(version: 1) @httpClient(subgraph: "Reviews", baseAddress: "http:\ } type Query { - authorById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }") - productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }") + productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) reviews: [Review!]! @resolver(subgraph: "Reviews", select: "{ reviews }") - userById(id: Int!): User! @variable(subgraph: "Accounts", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }") + userById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) @variable(subgraph: "Accounts", name: "id", argument: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) users: [User!]! @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [Int!]!): [User!]! @variable(subgraph: "Accounts", name: "ids", argument: "ids") @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[Int!]!" } ]) } -type Product @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Reviews", name: "Product_upc", select: "upc", type: "Int!") { +type Product @variable(subgraph: "Reviews", name: "Product_upc", select: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) { reviews: [Review!]! @source(subgraph: "Reviews") upc: Int! @source(subgraph: "Reviews") } @@ -22,7 +22,7 @@ type Review { product: Product! @source(subgraph: "Reviews") } -type User @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }") @variable(subgraph: "Reviews", name: "User_id", select: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }") @variable(subgraph: "Accounts", name: "User_id", select: "id", type: "Int!") { +type User @source(subgraph: "Reviews", name: "Author") @variable(subgraph: "Reviews", name: "User_id", select: "id") @variable(subgraph: "Accounts", name: "User_id", select: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[Int!]!" } ], kind: "BATCH_BY_KEY") { birthdate: DateTime! @source(subgraph: "Accounts") id: Int! @source(subgraph: "Reviews") @source(subgraph: "Accounts") name: String! @source(subgraph: "Reviews") @source(subgraph: "Accounts") diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Batch_Requests.snap b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Batch_Requests.snap new file mode 100644 index 00000000000..2c7cf99cbfd --- /dev/null +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Batch_Requests.snap @@ -0,0 +1,128 @@ +User Request +--------------- +query GetUser { + reviews { + body + author { + birthdate + } + } +} +--------------- + +QueryPlan +--------------- +{ + "document": "query GetUser { reviews { body author { birthdate } } }", + "operation": "GetUser", + "rootNode": { + "type": "Serial", + "nodes": [ + { + "type": "Resolver", + "schemaName": "Reviews", + "document": "query GetUser_1 { reviews { body author { __fusion_exports__1: id } } }", + "selectionSetId": 0 + }, + { + "type": "Composition", + "selectionSetIds": [ + 0 + ] + }, + { + "type": "BatchResolver", + "schemaName": "Accounts", + "document": "query GetUser_2($__fusion_exports__1: [Int!]!) { usersById(ids: $__fusion_exports__1) { birthdate __fusion_exports__1: id } }", + "selectionSetId": 2, + "path": [ + "usersById" + ], + "requires": [ + { + "variable": "__fusion_exports__1" + } + ] + }, + { + "type": "Composition", + "selectionSetIds": [ + 2 + ] + } + ] + } +} +--------------- + +Result +--------------- +{ + "data": { + "reviews": [ + { + "body": "Love it!", + "author": { + "birthdate": "1815-12-10T00:00:00.000\u002B00:35" + } + }, + { + "body": "Too expensive.", + "author": { + "birthdate": "1912-06-23T00:00:00.000\u002B01:00" + } + }, + { + "body": "Could be better.", + "author": { + "birthdate": "1815-12-10T00:00:00.000\u002B00:35" + } + }, + { + "body": "Prefer something else.", + "author": { + "birthdate": "1912-06-23T00:00:00.000\u002B01:00" + } + } + ] + } +} +--------------- + +Fusion Graph +--------------- +schema @fusion(version: 1) @httpClient(subgraph: "Reviews", baseAddress: "http:\/\/localhost:5000\/graphql") @httpClient(subgraph: "Accounts", baseAddress: "http:\/\/localhost:5000\/graphql") { + query: Query +} + +type Query { + productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) + reviews: [Review!]! @resolver(subgraph: "Reviews", select: "{ reviews }") + userById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) @variable(subgraph: "Accounts", name: "id", argument: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) + users: [User!]! @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [Int!]!): [User!]! @variable(subgraph: "Accounts", name: "ids", argument: "ids") @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[Int!]!" } ]) +} + +type Product @variable(subgraph: "Reviews", name: "Product_upc", select: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) { + reviews: [Review!]! @source(subgraph: "Reviews") + upc: Int! @source(subgraph: "Reviews") +} + +type Review { + author: User! @source(subgraph: "Reviews") + body: String! @source(subgraph: "Reviews") + id: Int! @source(subgraph: "Reviews") + product: Product! @source(subgraph: "Reviews") +} + +type User @source(subgraph: "Reviews", name: "Author") @variable(subgraph: "Reviews", name: "User_id", select: "id") @variable(subgraph: "Accounts", name: "User_id", select: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[Int!]!" } ], kind: "BATCH_BY_KEY") { + birthdate: DateTime! @source(subgraph: "Accounts") + id: Int! @source(subgraph: "Reviews") @source(subgraph: "Accounts") + name: String! @source(subgraph: "Reviews") @source(subgraph: "Accounts") + reviews: [Review!]! @source(subgraph: "Reviews") + username: String! @source(subgraph: "Accounts") +} + +"The `DateTime` scalar represents an ISO-8601 compliant date time type." +scalar DateTime +--------------- diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_GetUserReviews.snap b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_GetUserReviews.snap index 48422d95445..6ef7508c75c 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_GetUserReviews.snap +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_GetUserReviews.snap @@ -109,14 +109,14 @@ schema @fusion(version: 1) @httpClient(subgraph: "Reviews", baseAddress: "http:\ } type Query { - authorById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }") - productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }") + productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) reviews: [Review!]! @resolver(subgraph: "Reviews", select: "{ reviews }") - userById(id: Int!): User! @variable(subgraph: "Accounts", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }") + userById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) @variable(subgraph: "Accounts", name: "id", argument: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) users: [User!]! @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [Int!]!): [User!]! @variable(subgraph: "Accounts", name: "ids", argument: "ids") @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[Int!]!" } ]) } -type Product @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Reviews", name: "Product_upc", select: "upc", type: "Int!") { +type Product @variable(subgraph: "Reviews", name: "Product_upc", select: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) { reviews: [Review!]! @source(subgraph: "Reviews") upc: Int! @source(subgraph: "Reviews") } @@ -128,7 +128,7 @@ type Review { product: Product! @source(subgraph: "Reviews") } -type User @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }") @variable(subgraph: "Reviews", name: "User_id", select: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }") @variable(subgraph: "Accounts", name: "User_id", select: "id", type: "Int!") { +type User @source(subgraph: "Reviews", name: "Author") @variable(subgraph: "Reviews", name: "User_id", select: "id") @variable(subgraph: "Accounts", name: "User_id", select: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[Int!]!" } ], kind: "BATCH_BY_KEY") { birthdate: DateTime! @source(subgraph: "Accounts") id: Int! @source(subgraph: "Reviews") @source(subgraph: "Accounts") name: String! @source(subgraph: "Reviews") @source(subgraph: "Accounts") diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_ReviewsUser.snap b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_ReviewsUser.snap index 489d7ca01dd..a35f1fb5de5 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_ReviewsUser.snap +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_ReviewsUser.snap @@ -184,14 +184,14 @@ schema @fusion(version: 1) @httpClient(subgraph: "Reviews", baseAddress: "http:\ } type Query { - authorById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }") - productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc", type: "Int!") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }") + productById(upc: Int!): Product! @variable(subgraph: "Reviews", name: "upc", argument: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $upc) }", arguments: [ { name: "upc", type: "Int!" } ]) reviews: [Review!]! @resolver(subgraph: "Reviews", select: "{ reviews }") - userById(id: Int!): User! @variable(subgraph: "Accounts", name: "id", argument: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }") + userById(id: Int!): User! @variable(subgraph: "Reviews", name: "id", argument: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) @variable(subgraph: "Accounts", name: "id", argument: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "Int!" } ]) users: [User!]! @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [Int!]!): [User!]! @variable(subgraph: "Accounts", name: "ids", argument: "ids") @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[Int!]!" } ]) } -type Product @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }") @variable(subgraph: "Reviews", name: "Product_upc", select: "upc", type: "Int!") { +type Product @variable(subgraph: "Reviews", name: "Product_upc", select: "upc") @resolver(subgraph: "Reviews", select: "{ productById(upc: $Product_upc) }", arguments: [ { name: "Product_upc", type: "Int!" } ]) { reviews: [Review!]! @source(subgraph: "Reviews") upc: Int! @source(subgraph: "Reviews") } @@ -203,7 +203,7 @@ type Review { product: Product! @source(subgraph: "Reviews") } -type User @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }") @variable(subgraph: "Reviews", name: "User_id", select: "id", type: "Int!") @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }") @variable(subgraph: "Accounts", name: "User_id", select: "id", type: "Int!") { +type User @source(subgraph: "Reviews", name: "Author") @variable(subgraph: "Reviews", name: "User_id", select: "id") @variable(subgraph: "Accounts", name: "User_id", select: "id") @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }", arguments: [ { name: "User_id", type: "Int!" } ]) @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[Int!]!" } ], kind: "BATCH_BY_KEY") { birthdate: DateTime! @source(subgraph: "Accounts") id: Int! @source(subgraph: "Reviews") @source(subgraph: "Accounts") name: String! @source(subgraph: "Reviews") @source(subgraph: "Accounts") diff --git a/src/HotChocolate/Fusion/test/Shared/Accounts/AccountQuery.cs b/src/HotChocolate/Fusion/test/Shared/Accounts/AccountQuery.cs new file mode 100644 index 00000000000..ea6c96fb9b6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Shared/Accounts/AccountQuery.cs @@ -0,0 +1,27 @@ +namespace HotChocolate.Fusion.Shared.Accounts; + +[GraphQLName("Query")] +public class AccountQuery +{ + public IEnumerable GetUsers([Service] UserRepository repository) => + repository.GetUsers(); + + public User? GetUserById(int id, [Service] UserRepository repository) => + repository.GetUser(id); + + public IEnumerable GetUsersById( + IEnumerable ids, + [Service] UserRepository repository) + { + foreach (var id in ids) + { + var user = repository.GetUser(id); + + if (user is not null) + { + yield return user; + } + } + } + +} diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Accounts/User.cs b/src/HotChocolate/Fusion/test/Shared/Accounts/User.cs similarity index 62% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/Accounts/User.cs rename to src/HotChocolate/Fusion/test/Shared/Accounts/User.cs index 94f5b48cc33..60561e5dfd1 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Accounts/User.cs +++ b/src/HotChocolate/Fusion/test/Shared/Accounts/User.cs @@ -1,3 +1,3 @@ -namespace HotChocolate.Fusion.Schemas.Accounts; +namespace HotChocolate.Fusion.Shared.Accounts; public record User(int Id, string Name, DateTime Birthdate, string Username); diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Accounts/UserRepository.cs b/src/HotChocolate/Fusion/test/Shared/Accounts/UserRepository.cs similarity index 66% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/Accounts/UserRepository.cs rename to src/HotChocolate/Fusion/test/Shared/Accounts/UserRepository.cs index 211a195c831..e1ea451dada 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Accounts/UserRepository.cs +++ b/src/HotChocolate/Fusion/test/Shared/Accounts/UserRepository.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Schemas.Accounts; +namespace HotChocolate.Fusion.Shared.Accounts; public class UserRepository { @@ -13,7 +13,15 @@ public UserRepository() }.ToDictionary(t => t.Id); } - public User GetUser(int id) => _users[id]; + public User? GetUser(int id) + { + if (_users.TryGetValue(id, out var value)) + { + return value; + } + + return null; + } public IEnumerable GetUsers() => _users.Values; } diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/DemoProject.cs b/src/HotChocolate/Fusion/test/Shared/DemoProject.cs similarity index 95% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/DemoProject.cs rename to src/HotChocolate/Fusion/test/Shared/DemoProject.cs index dc11f42a0af..71ab1e9d234 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/DemoProject.cs +++ b/src/HotChocolate/Fusion/test/Shared/DemoProject.cs @@ -1,12 +1,12 @@ -using System.Diagnostics.Contracts; -using HotChocolate.Fusion.Schemas.Accounts; -using HotChocolate.Fusion.Schemas.Products; -using HotChocolate.Fusion.Schemas.Reviews; +using HotChocolate.AspNetCore.Tests.Utilities; +using HotChocolate.Fusion.Shared.Accounts; +using HotChocolate.Fusion.Shared.Products; +using HotChocolate.Fusion.Shared.Reviews; using HotChocolate.Utilities.Introspection; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -namespace HotChocolate.Fusion.Schemas; +namespace HotChocolate.Fusion.Shared; public sealed class DemoProject : IDisposable { diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/DemoSubgraph.cs b/src/HotChocolate/Fusion/test/Shared/DemoSubgraph.cs similarity index 95% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/DemoSubgraph.cs rename to src/HotChocolate/Fusion/test/Shared/DemoSubgraph.cs index 34d997779e9..411eab57d45 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/DemoSubgraph.cs +++ b/src/HotChocolate/Fusion/test/Shared/DemoSubgraph.cs @@ -2,7 +2,7 @@ using HotChocolate.Language; using Microsoft.AspNetCore.TestHost; -namespace HotChocolate.Fusion.Schemas; +namespace HotChocolate.Fusion.Shared; public sealed class DemoSubgraph { diff --git a/src/HotChocolate/Fusion/test/Shared/HotChocolate.Fusion.Tests.Shared.csproj b/src/HotChocolate/Fusion/test/Shared/HotChocolate.Fusion.Tests.Shared.csproj new file mode 100644 index 00000000000..b911fae2a22 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Shared/HotChocolate.Fusion.Tests.Shared.csproj @@ -0,0 +1,16 @@ + + + + net7.0 + enable + enable + HotChocolate.Fusion.Shared + + + + + + + + + diff --git a/src/HotChocolate/Fusion/test/Core.Tests/MockHttpClientFactory.cs b/src/HotChocolate/Fusion/test/Shared/MockHttpClientFactory.cs similarity index 89% rename from src/HotChocolate/Fusion/test/Core.Tests/MockHttpClientFactory.cs rename to src/HotChocolate/Fusion/test/Shared/MockHttpClientFactory.cs index 23174f97176..3d2f9c05574 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/MockHttpClientFactory.cs +++ b/src/HotChocolate/Fusion/test/Shared/MockHttpClientFactory.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion; +namespace HotChocolate.Fusion.Shared; public class MockHttpClientFactory : IHttpClientFactory { diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Products/Product.cs b/src/HotChocolate/Fusion/test/Shared/Products/Product.cs similarity index 58% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/Products/Product.cs rename to src/HotChocolate/Fusion/test/Shared/Products/Product.cs index c372011618d..e50761a05d9 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Products/Product.cs +++ b/src/HotChocolate/Fusion/test/Shared/Products/Product.cs @@ -1,3 +1,3 @@ -namespace HotChocolate.Fusion.Schemas.Products; +namespace HotChocolate.Fusion.Shared.Products; public record Product(int Upc, string Name, int Price, int Weight); diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Products/ProductRepository.cs b/src/HotChocolate/Fusion/test/Shared/Products/ProductRepository.cs similarity index 91% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/Products/ProductRepository.cs rename to src/HotChocolate/Fusion/test/Shared/Products/ProductRepository.cs index 9b7004a1b70..15ad7b35857 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Products/ProductRepository.cs +++ b/src/HotChocolate/Fusion/test/Shared/Products/ProductRepository.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Schemas.Products; +namespace HotChocolate.Fusion.Shared.Products; public class ProductRepository { diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Products/Query.cs b/src/HotChocolate/Fusion/test/Shared/Products/Query.cs similarity index 88% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/Products/Query.cs rename to src/HotChocolate/Fusion/test/Shared/Products/Query.cs index 904c6fe3a81..a2a8a3f999c 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Products/Query.cs +++ b/src/HotChocolate/Fusion/test/Shared/Products/Query.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Schemas.Products; +namespace HotChocolate.Fusion.Shared.Products; [GraphQLName("Query")] public class ProductQuery diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/Author.cs b/src/HotChocolate/Fusion/test/Shared/Reviews/Author.cs similarity index 86% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/Author.cs rename to src/HotChocolate/Fusion/test/Shared/Reviews/Author.cs index 681ebc8c951..d8e31a72c98 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/Author.cs +++ b/src/HotChocolate/Fusion/test/Shared/Reviews/Author.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Schemas.Reviews; +namespace HotChocolate.Fusion.Shared.Reviews; public sealed class Author { diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/Product.cs b/src/HotChocolate/Fusion/test/Shared/Reviews/Product.cs similarity index 84% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/Product.cs rename to src/HotChocolate/Fusion/test/Shared/Reviews/Product.cs index e12c7cb4078..c6bf0af5195 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/Product.cs +++ b/src/HotChocolate/Fusion/test/Shared/Reviews/Product.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Schemas.Reviews; +namespace HotChocolate.Fusion.Shared.Reviews; public sealed class Product { diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/Review.cs b/src/HotChocolate/Fusion/test/Shared/Reviews/Review.cs similarity index 61% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/Review.cs rename to src/HotChocolate/Fusion/test/Shared/Reviews/Review.cs index a8b49167eb9..f2ed532473b 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/Review.cs +++ b/src/HotChocolate/Fusion/test/Shared/Reviews/Review.cs @@ -1,3 +1,3 @@ -namespace HotChocolate.Fusion.Schemas.Reviews; +namespace HotChocolate.Fusion.Shared.Reviews; public record Review(int Id, Author Author, Product Product, string Body); diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/ReviewQuery.cs b/src/HotChocolate/Fusion/test/Shared/Reviews/ReviewQuery.cs similarity index 90% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/ReviewQuery.cs rename to src/HotChocolate/Fusion/test/Shared/Reviews/ReviewQuery.cs index 72bc6ddb156..667d58405b5 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/ReviewQuery.cs +++ b/src/HotChocolate/Fusion/test/Shared/Reviews/ReviewQuery.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Schemas.Reviews; +namespace HotChocolate.Fusion.Shared.Reviews; [GraphQLName("Query")] public sealed class ReviewQuery diff --git a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/ReviewRepository.cs b/src/HotChocolate/Fusion/test/Shared/Reviews/ReviewRepository.cs similarity index 96% rename from src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/ReviewRepository.cs rename to src/HotChocolate/Fusion/test/Shared/Reviews/ReviewRepository.cs index 7e3acd8ca92..5baa8f39242 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/Schemas/Reviews/ReviewRepository.cs +++ b/src/HotChocolate/Fusion/test/Shared/Reviews/ReviewRepository.cs @@ -1,4 +1,4 @@ -namespace HotChocolate.Fusion.Schemas.Reviews; +namespace HotChocolate.Fusion.Shared.Reviews; public class ReviewRepository { diff --git a/src/HotChocolate/Skimmed/src/Skimmed/Extensions/TypeExtensions.cs b/src/HotChocolate/Skimmed/src/Skimmed/Extensions/TypeExtensions.cs index 53964c8667a..1d0c1e21188 100644 --- a/src/HotChocolate/Skimmed/src/Skimmed/Extensions/TypeExtensions.cs +++ b/src/HotChocolate/Skimmed/src/Skimmed/Extensions/TypeExtensions.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using HotChocolate.Language; namespace HotChocolate.Skimmed; @@ -24,6 +25,7 @@ public static bool IsOutputType(this IType type) _ => throw new NotSupportedException(), }; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static IType InnerType(this IType type) { switch (type) @@ -31,7 +33,6 @@ public static IType InnerType(this IType type) case ListType listType: return listType.ElementType; - case NonNullType nonNullType: return nonNullType.NullableType; diff --git a/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaFormatter.cs b/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaFormatter.cs index 26806f77177..309399dad76 100644 --- a/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaFormatter.cs +++ b/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaFormatter.cs @@ -263,6 +263,58 @@ type.Description is not null directives); } + public override void VisitEnumType(EnumType type, VisitorContext context) + { + VisitDirectives(type.Directives, context); + var directives = (List)context.Result!; + + VisitEnumValues(type.Values, context); + var values = (List)context.Result!; + + context.Result = + type.ContextData.ContainsKey(WellKnownContextData.TypeExtension) + ? new EnumTypeExtensionNode( + null, + new NameNode(type.Name), + directives, + values) + : new EnumTypeDefinitionNode( + null, + new NameNode(type.Name), + type.Description is not null + ? new StringValueNode(type.Description) + : null, + directives, + values); + } + + public override void VisitEnumValues(EnumValueCollection values, VisitorContext context) + { + var definitionNodes = new List(); + + foreach (var value in values.OrderBy(t => t.Name)) + { + VisitEnumValue(value, context); + definitionNodes.Add((EnumValueDefinitionNode)context.Result!); + } + + context.Result = definitionNodes; + } + + public override void VisitEnumValue(EnumValue value, VisitorContext context) + { + VisitDirectives(value.Directives, context); + var directives = (List)context.Result!; + + context.Result = new EnumValueDefinitionNode( + null, + new NameNode(value.Name), + value.Description is not null + ? new StringValueNode(value.Description) + : null, + directives); + } + public override void VisitUnionType(UnionType type, VisitorContext context) { VisitDirectives(type.Directives, context); diff --git a/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaParser.cs b/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaParser.cs index f71b6152059..6aa271201ed 100644 --- a/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaParser.cs +++ b/src/HotChocolate/Skimmed/src/Skimmed/Serialization/SchemaParser.cs @@ -612,6 +612,7 @@ private static void BuildDirectiveCollection( out var directiveType)) { directiveType = new DirectiveType(directiveNode.Name.Value); + directiveType.IsRepeatable = true; schema.DirectiveTypes.Add(directiveType); } diff --git a/src/HotChocolate/Utilities/src/Utilities.Introspection/IntrospectionClient.cs b/src/HotChocolate/Utilities/src/Utilities.Introspection/IntrospectionClient.cs index ecad86632e4..0ec86550802 100644 --- a/src/HotChocolate/Utilities/src/Utilities.Introspection/IntrospectionClient.cs +++ b/src/HotChocolate/Utilities/src/Utilities.Introspection/IntrospectionClient.cs @@ -29,7 +29,7 @@ static IntrospectionClient() internal static JsonSerializerOptions SerializerOptions => _serializerOptions; - public static IntrospectionClient Default { get; } = new IntrospectionClient(); + public static IntrospectionClient Default { get; } = new(); public async Task DownloadSchemaAsync( HttpClient client, diff --git a/src/HotChocolate/Utilities/src/Utilities.Introspection/SchemaFeatures.cs b/src/HotChocolate/Utilities/src/Utilities.Introspection/SchemaFeatures.cs index be7bea664d9..f6af2a3388c 100644 --- a/src/HotChocolate/Utilities/src/Utilities.Introspection/SchemaFeatures.cs +++ b/src/HotChocolate/Utilities/src/Utilities.Introspection/SchemaFeatures.cs @@ -27,6 +27,7 @@ internal static SchemaFeatures FromIntrospectionResult( var directive = result.Data.Schema.Types.FirstOrDefault(t => t.Name.Equals(__Directive, StringComparison.Ordinal)); + if (directive is not null) { features.HasRepeatableDirectives = directive.Fields.Any(t => @@ -37,6 +38,7 @@ internal static SchemaFeatures FromIntrospectionResult( var schema = result.Data.Schema.Types.FirstOrDefault(t => t.Name.Equals(__Schema, StringComparison.Ordinal)); + if (schema is not null) { features.HasSubscriptionSupport = schema.Fields.Any(t =>