From d8439f1ae45639c08131bda8d3ec5c5662b331bd Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 24 Dec 2024 13:37:58 +0100 Subject: [PATCH] Added Fusion Cost Analyzer Improvements. (#7867) --- .../Configuration/AggregateTypeInterceptor.cs | 16 ++ .../Types/Configuration/TypeInterceptor.cs | 3 + .../Types/Factories/SchemaSyntaxVisitor.cs | 5 + .../CostAnalysis/CostAnalyzerMiddleware.cs | 2 +- .../src/CostAnalysis/CostTypeInterceptor.cs | 7 + ...nalyzerRequestExecutorBuilderExtensions.cs | 2 + .../CostAnalyzerRequestContextExtensions.cs | 9 +- .../HotChocolate.CostAnalysis.csproj | 1 + .../src/CostAnalysis/Options/CostOptions.cs | 33 +++- .../Options/RequestCostOptions.cs | 159 ++++++++++++++++-- .../Composition/Extensions/MergeExtensions.cs | 21 +++ .../FusionRequestExecutorBuilderExtensions.cs | 12 +- .../Composition.Tests/DemoIntegrationTests.cs | 18 ++ ...sts.Accounts_And_Reviews_With_Cost.graphql | 148 ++++++++++++++++ .../test/Core.Tests/DemoIntegrationTests.cs | 52 ++++++ ..._And_Reviews_And_Products_Introspection.md | 4 +- ...eviews_Query_GetUserReviews_Report_Cost.md | 129 ++++++++++++++ .../test/Shared/Accounts/AccountQuery.cs | 1 + .../Fusion/test/Shared/DemoProject.cs | 42 +++-- .../Shared/DemoProjectSchemaExtensions.cs | 44 +++++ .../test/Shared/Reviews/ReviewsQuery.cs | 1 + .../src/Skimmed/DirectiveDefinition.cs | 12 +- 22 files changed, 682 insertions(+), 39 deletions(-) create mode 100644 src/HotChocolate/Fusion/test/Composition.Tests/__snapshots__/DemoIntegrationTests.Accounts_And_Reviews_With_Cost.graphql create mode 100644 src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_GetUserReviews_Report_Cost.md diff --git a/src/HotChocolate/Core/src/Types/Configuration/AggregateTypeInterceptor.cs b/src/HotChocolate/Core/src/Types/Configuration/AggregateTypeInterceptor.cs index e7a6c55921c..adbb3a39111 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/AggregateTypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/AggregateTypeInterceptor.cs @@ -110,6 +110,22 @@ internal override void InitializeContext( } } + internal override bool SkipDirectiveDefinition(DirectiveDefinitionNode node) + { + ref var first = ref GetReference(); + var length = _typeInterceptors.Length; + + for (var i = 0; i < length; i++) + { + if (Unsafe.Add(ref first, i).SkipDirectiveDefinition(node)) + { + return true; + } + } + + return false; + } + public override void OnBeforeDiscoverTypes() { ref var first = ref GetReference(); diff --git a/src/HotChocolate/Core/src/Types/Configuration/TypeInterceptor.cs b/src/HotChocolate/Core/src/Types/Configuration/TypeInterceptor.cs index 5b2d812414a..658292eaf48 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/TypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/TypeInterceptor.cs @@ -74,6 +74,9 @@ public virtual void OnBeforeDiscoverTypes() { } /// public virtual void OnAfterDiscoverTypes() { } + internal virtual bool SkipDirectiveDefinition(DirectiveDefinitionNode node) + => false; + /// /// This event is triggered after the type instance was created but before /// any type definition was initialized. diff --git a/src/HotChocolate/Core/src/Types/Types/Factories/SchemaSyntaxVisitor.cs b/src/HotChocolate/Core/src/Types/Types/Factories/SchemaSyntaxVisitor.cs index 0d2b01c50cd..4f1aec7cab4 100644 --- a/src/HotChocolate/Core/src/Types/Types/Factories/SchemaSyntaxVisitor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Factories/SchemaSyntaxVisitor.cs @@ -171,6 +171,11 @@ protected override ISyntaxVisitorAction VisitChildren( goto EXIT; } + if(context.DescriptorContext.TypeInterceptor.SkipDirectiveDefinition(node)) + { + goto EXIT; + } + context.Types.Add( TypeReference.Create( _directiveTypeFactory.Create( diff --git a/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostAnalyzerMiddleware.cs b/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostAnalyzerMiddleware.cs index a13cc8c04c2..66290d93cd0 100644 --- a/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostAnalyzerMiddleware.cs +++ b/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostAnalyzerMiddleware.cs @@ -36,7 +36,7 @@ public async ValueTask InvokeAsync(IRequestContext context) } var requestOptions = context.TryGetCostOptions() ?? options; - var mode = context.GetCostAnalyzerMode(requestOptions.EnforceCostLimits); + var mode = context.GetCostAnalyzerMode(requestOptions); if (mode == CostAnalyzerMode.Skip) { diff --git a/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostTypeInterceptor.cs b/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostTypeInterceptor.cs index ae769173431..514c9c95aa6 100644 --- a/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostTypeInterceptor.cs +++ b/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostTypeInterceptor.cs @@ -171,6 +171,13 @@ public override void OnBeforeCompleteType(ITypeCompletionContext completionConte } } +internal sealed class CostDirectiveTypeInterceptor : TypeInterceptor +{ + internal override bool SkipDirectiveDefinition(DirectiveDefinitionNode node) + => node.Name.Value.Equals("cost", StringComparison.Ordinal) + || node.Name.Value.Equals("listSize", StringComparison.Ordinal); +} + file static class Extensions { public static bool HasCostDirective(this IHasDirectiveDefinition directiveProvider) diff --git a/src/HotChocolate/CostAnalysis/src/CostAnalysis/DependencyInjection/CostAnalyzerRequestExecutorBuilderExtensions.cs b/src/HotChocolate/CostAnalysis/src/CostAnalysis/DependencyInjection/CostAnalyzerRequestExecutorBuilderExtensions.cs index 64560d2c558..7777311ac9e 100644 --- a/src/HotChocolate/CostAnalysis/src/CostAnalysis/DependencyInjection/CostAnalyzerRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/CostAnalysis/src/CostAnalysis/DependencyInjection/CostAnalyzerRequestExecutorBuilderExtensions.cs @@ -56,12 +56,14 @@ public static IRequestExecutorBuilder AddCostAnalyzer(this IRequestExecutorBuild requestOptions.MaxFieldCost, requestOptions.MaxTypeCost, requestOptions.EnforceCostLimits, + requestOptions.SkipAnalyzer, requestOptions.Filtering.VariableMultiplier); }); }) .AddDirectiveType() .AddDirectiveType() .TryAddTypeInterceptor() + .TryAddTypeInterceptor() // we are replacing the default pipeline if the cost analyzer is added. .Configure(c => c.DefaultPipelineFactory = AddDefaultPipeline); diff --git a/src/HotChocolate/CostAnalysis/src/CostAnalysis/Extensions/CostAnalyzerRequestContextExtensions.cs b/src/HotChocolate/CostAnalysis/src/CostAnalysis/Extensions/CostAnalyzerRequestContextExtensions.cs index e3c42d18810..e768a648434 100644 --- a/src/HotChocolate/CostAnalysis/src/CostAnalysis/Extensions/CostAnalyzerRequestContextExtensions.cs +++ b/src/HotChocolate/CostAnalysis/src/CostAnalysis/Extensions/CostAnalyzerRequestContextExtensions.cs @@ -58,13 +58,18 @@ public static CostMetrics GetCostMetrics( internal static CostAnalyzerMode GetCostAnalyzerMode( this IRequestContext context, - bool enforceCostLimits) + RequestCostOptions options) { if (context is null) { throw new ArgumentNullException(nameof(context)); } + if (options.SkipAnalyzer) + { + return CostAnalyzerMode.Skip; + } + if (context.ContextData.ContainsKey(WellKnownContextData.ValidateCost)) { return CostAnalyzerMode.Analyze | CostAnalyzerMode.Report; @@ -72,7 +77,7 @@ internal static CostAnalyzerMode GetCostAnalyzerMode( var flags = CostAnalyzerMode.Analyze; - if (enforceCostLimits) + if (options.EnforceCostLimits) { flags |= CostAnalyzerMode.Enforce; } diff --git a/src/HotChocolate/CostAnalysis/src/CostAnalysis/HotChocolate.CostAnalysis.csproj b/src/HotChocolate/CostAnalysis/src/CostAnalysis/HotChocolate.CostAnalysis.csproj index e3fdbd68bfd..c3bd3025c3f 100644 --- a/src/HotChocolate/CostAnalysis/src/CostAnalysis/HotChocolate.CostAnalysis.csproj +++ b/src/HotChocolate/CostAnalysis/src/CostAnalysis/HotChocolate.CostAnalysis.csproj @@ -2,6 +2,7 @@ + diff --git a/src/HotChocolate/CostAnalysis/src/CostAnalysis/Options/CostOptions.cs b/src/HotChocolate/CostAnalysis/src/CostAnalysis/Options/CostOptions.cs index b036e72187b..3fd1ec5f593 100644 --- a/src/HotChocolate/CostAnalysis/src/CostAnalysis/Options/CostOptions.cs +++ b/src/HotChocolate/CostAnalysis/src/CostAnalysis/Options/CostOptions.cs @@ -5,6 +5,9 @@ namespace HotChocolate.CostAnalysis; /// public sealed class CostOptions { + private bool _skipAnalyzer = false; + private bool _enforceCostLimits = true; + /// /// Gets or sets the maximum allowed field cost. /// @@ -18,7 +21,35 @@ public sealed class CostOptions /// /// Defines if the analyzer shall enforce cost limits. /// - public bool EnforceCostLimits { get; set; } = true; + public bool EnforceCostLimits + { + get => _enforceCostLimits; + set + { + if(value) + { + SkipAnalyzer = false; + } + + _enforceCostLimits = value; + } + } + + /// + /// Skips the cost analyzer. + /// + public bool SkipAnalyzer + { + get => _skipAnalyzer; + set + { + if(value) + { + EnforceCostLimits = false; + } + _skipAnalyzer = value; + } + } /// /// Defines if cost defaults shall be applied to the schema. diff --git a/src/HotChocolate/CostAnalysis/src/CostAnalysis/Options/RequestCostOptions.cs b/src/HotChocolate/CostAnalysis/src/CostAnalysis/Options/RequestCostOptions.cs index 0b111285f7d..6d64d41630f 100644 --- a/src/HotChocolate/CostAnalysis/src/CostAnalysis/Options/RequestCostOptions.cs +++ b/src/HotChocolate/CostAnalysis/src/CostAnalysis/Options/RequestCostOptions.cs @@ -3,20 +3,145 @@ namespace HotChocolate.CostAnalysis; /// /// Request options for cost analysis. /// -/// -/// The maximum allowed field cost. -/// -/// -/// The maximum allowed type cost. -/// -/// -/// Defines if the analyzer shall enforce cost limits. -/// -/// -/// The filter variable multiplier. -/// -public record RequestCostOptions( - double MaxFieldCost, - double MaxTypeCost, - bool EnforceCostLimits, - int? FilterVariableMultiplier); +public record RequestCostOptions +{ + /// + /// Request options for cost analysis. + /// + /// + /// The maximum allowed field cost. + /// + /// + /// The maximum allowed type cost. + /// + /// + /// Defines if the analyzer shall enforce cost limits. + /// + /// + /// The filter variable multiplier. + /// + public RequestCostOptions( + double maxFieldCost, + double maxTypeCost, + bool enforceCostLimits, + int? filterVariableMultiplier) + { + MaxFieldCost = maxFieldCost; + MaxTypeCost = maxTypeCost; + EnforceCostLimits = enforceCostLimits; + FilterVariableMultiplier = filterVariableMultiplier; + } + + /// + /// Gets the maximum allowed field cost. + /// + /// + /// The maximum allowed field cost. + /// + /// + /// The maximum allowed type cost. + /// + /// + /// Defines if the analyzer shall enforce cost limits. + /// + /// + /// Defines if the cost analyzer shall be skipped. + /// + /// + /// The filter variable multiplier. + /// + public RequestCostOptions( + double maxFieldCost, + double maxTypeCost, + bool enforceCostLimits, + bool skipAnalyzer, + int? filterVariableMultiplier) + { + MaxFieldCost = maxFieldCost; + MaxTypeCost = maxTypeCost; + EnforceCostLimits = enforceCostLimits; + SkipAnalyzer = skipAnalyzer; + FilterVariableMultiplier = filterVariableMultiplier; + } + + /// + /// Gets the maximum allowed field cost. + /// + public double MaxFieldCost { get; init; } + + /// + /// Gets the maximum allowed type cost. + /// + public double MaxTypeCost { get; init; } + + /// + /// Defines if the analyzer shall enforce cost limits. + /// + public bool EnforceCostLimits + { + get; + init + { + if (value) + { + SkipAnalyzer = false; + } + + field = value; + } + } + + /// + /// Defines if the cost analyzer shall be skipped. + /// + public bool SkipAnalyzer + { + get; + init + { + if (value) + { + EnforceCostLimits = false; + } + + field = value; + } + } + + /// + /// Gets the filter variable multiplier. + /// + public int? FilterVariableMultiplier { get; init; } + + /// + /// Deconstructs the request options. + /// + /// + /// The maximum allowed field cost. + /// + /// + /// The maximum allowed type cost. + /// + /// + /// Defines if the analyzer shall enforce cost limits. + /// + /// + /// Defines if the cost analyzer shall be skipped. + /// + /// + /// The filter variable multiplier. + /// + public void Deconstruct( + out double maxFieldCost, + out double maxTypeCost, + out bool enforceCostLimits, + out bool skipAnalyzer, + out int? filterVariableMultiplier) + { + maxFieldCost = MaxFieldCost; + maxTypeCost = MaxTypeCost; + enforceCostLimits = EnforceCostLimits; + skipAnalyzer = SkipAnalyzer; + filterVariableMultiplier = FilterVariableMultiplier; + } +} diff --git a/src/HotChocolate/Fusion/src/Composition/Extensions/MergeExtensions.cs b/src/HotChocolate/Fusion/src/Composition/Extensions/MergeExtensions.cs index 49503d22ec0..4e504bd2907 100644 --- a/src/HotChocolate/Fusion/src/Composition/Extensions/MergeExtensions.cs +++ b/src/HotChocolate/Fusion/src/Composition/Extensions/MergeExtensions.cs @@ -1,5 +1,7 @@ +using HotChocolate.Language; using HotChocolate.Skimmed; using HotChocolate.Types; +using HotChocolate.Utilities; namespace HotChocolate.Fusion.Composition; @@ -189,6 +191,25 @@ internal static void MergeDirectivesWith( } else { + if (directive.Name.EqualsOrdinal("cost")) + { + var currentCost = target.Directives.FirstOrDefault("cost")!; + if (currentCost.Arguments.TryGetValue("weight", out var value) + && value is StringValueNode stringValueNode + && double.TryParse(stringValueNode.Value, out var currentWeight) + && directive.Arguments.TryGetValue("weight", out value) + && value is StringValueNode newStringValueNode + && double.TryParse(newStringValueNode.Value, out var newWeight) + && newWeight > currentWeight) + { + target.Directives.Remove(currentCost); + target.Directives.Add(directive); + } + + continue; + } + + if (directiveDefinition is not null && directiveDefinition.IsRepeatable) { target.Directives.Add(directive); diff --git a/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs b/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs index 12ba1e5490a..7deb82e82c6 100644 --- a/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/Fusion/src/Core/DependencyInjection/FusionRequestExecutorBuilderExtensions.cs @@ -1,4 +1,5 @@ using HotChocolate; +using HotChocolate.CostAnalysis; using HotChocolate.Execution; using HotChocolate.Execution.Configuration; using HotChocolate.Execution.Errors; @@ -30,6 +31,9 @@ public static class FusionRequestExecutorBuilderExtensions /// /// The name of the fusion graph. /// + /// + /// If set to true the default security policy is disabled. + /// /// /// Returns the that can be used to configure the Gateway. /// @@ -38,7 +42,8 @@ public static class FusionRequestExecutorBuilderExtensions /// public static FusionGatewayBuilder AddFusionGatewayServer( this IServiceCollection services, - string? graphName = null) + string? graphName = null, + bool disableDefaultSecurity = false) { ArgumentNullException.ThrowIfNull(services); @@ -53,11 +58,12 @@ public static FusionGatewayBuilder AddFusionGatewayServer( sp.GetRequiredService())); var builder = services - .AddGraphQLServer(graphName, disableDefaultSecurity: true) + .AddGraphQLServer(graphName, disableDefaultSecurity: disableDefaultSecurity) .UseField(next => next) .AddOperationCompilerOptimizer() .AddOperationCompilerOptimizer() .AddConvention(_ => new DefaultNamingConventions()) + .ModifyCostOptions(o => o.ApplyCostDefaults = false) .Configure( c => { @@ -562,6 +568,7 @@ private static IRequestExecutorBuilder UseFusionDefaultPipeline( .UseDocumentCache() .UseDocumentParser() .UseDocumentValidation() + .UseCostAnalyzer() .UseOperationCache() .UseOperationResolver() .UseSkipWarmupExecution() @@ -638,6 +645,7 @@ internal static void AddDefaultPipeline(this IList pipeli pipeline.Add(DocumentCacheMiddleware.Create()); pipeline.Add(DocumentParserMiddleware.Create()); pipeline.Add(DocumentValidationMiddleware.Create()); + pipeline.Add(CostAnalyzerMiddleware.Create()); pipeline.Add(OperationCacheMiddleware.Create()); pipeline.Add(OperationResolverMiddleware.Create()); pipeline.Add(OperationVariableCoercionMiddleware.Create()); diff --git a/src/HotChocolate/Fusion/test/Composition.Tests/DemoIntegrationTests.cs b/src/HotChocolate/Fusion/test/Composition.Tests/DemoIntegrationTests.cs index dd15057c9d6..9192af30607 100644 --- a/src/HotChocolate/Fusion/test/Composition.Tests/DemoIntegrationTests.cs +++ b/src/HotChocolate/Fusion/test/Composition.Tests/DemoIntegrationTests.cs @@ -26,6 +26,24 @@ public async Task Accounts_And_Reviews() fusionConfig.MatchSnapshot(extension: ".graphql"); } + [Fact] + public async Task Accounts_And_Reviews_With_Cost() + { + // arrange + using var demoProject = await DemoProject.CreateAsync(enableCost: true); + + var composer = new FusionGraphComposer(logFactory: _logFactory); + + var fusionConfig = await composer.ComposeAsync( + [ + demoProject.Accounts.ToConfiguration(AccountsExtensionWithCostSdl), + demoProject.Reviews.ToConfiguration(ReviewsExtensionWithCostSdl) + ]); + + fusionConfig.MatchSnapshot(extension: ".graphql"); + } + + [Fact] public async Task Accounts_And_Reviews_Infer_Patterns() { diff --git a/src/HotChocolate/Fusion/test/Composition.Tests/__snapshots__/DemoIntegrationTests.Accounts_And_Reviews_With_Cost.graphql b/src/HotChocolate/Fusion/test/Composition.Tests/__snapshots__/DemoIntegrationTests.Accounts_And_Reviews_With_Cost.graphql new file mode 100644 index 00000000000..06f25f2f81a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Composition.Tests/__snapshots__/DemoIntegrationTests.Accounts_And_Reviews_With_Cost.graphql @@ -0,0 +1,148 @@ +schema + @fusion(version: 1) + @transport(subgraph: "Accounts", location: "http:\/\/localhost:5000\/graphql", kind: "HTTP") + @transport(subgraph: "Accounts", location: "ws:\/\/localhost:5000\/graphql", kind: "WebSocket") + @transport(subgraph: "Reviews", location: "http:\/\/localhost:5000\/graphql", kind: "HTTP") + @transport(subgraph: "Reviews", location: "ws:\/\/localhost:5000\/graphql", kind: "WebSocket") { + query: Query + mutation: Mutation + subscription: Subscription +} + +type Query { + errorField: String + @resolver(subgraph: "Accounts", select: "{ errorField }") + productById(id: ID!): Product + @variable(subgraph: "Reviews", name: "id", argument: "id") + @resolver(subgraph: "Reviews", select: "{ productById(id: $id) }", arguments: [ { name: "id", type: "ID!" } ]) + reviewById(id: ID!): Review + @variable(subgraph: "Reviews", name: "id", argument: "id") + @resolver(subgraph: "Reviews", select: "{ reviewById(id: $id) }", arguments: [ { name: "id", type: "ID!" } ]) + reviewOrAuthor: ReviewOrAuthor! + @resolver(subgraph: "Reviews", select: "{ reviewOrAuthor }") + reviews: [Review!]! + @resolver(subgraph: "Reviews", select: "{ reviews }") + userById(id: ID!): User + @variable(subgraph: "Accounts", name: "id", argument: "id") + @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "ID!" } ]) + @cost(weight: "2.0") + @variable(subgraph: "Reviews", name: "id", argument: "id") + @resolver(subgraph: "Reviews", select: "{ authorById(id: $id) }", arguments: [ { name: "id", type: "ID!" } ]) + users: [User!]! + @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [ID!]!): [User!]! + @variable(subgraph: "Accounts", name: "ids", argument: "ids") + @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[ID!]!" } ]) + viewer: Viewer! + @resolver(subgraph: "Accounts", select: "{ viewer }") +} + +type Mutation { + addReview(input: AddReviewInput!): AddReviewPayload! + @variable(subgraph: "Reviews", name: "input", argument: "input") + @resolver(subgraph: "Reviews", select: "{ addReview(input: $input) }", arguments: [ { name: "input", type: "AddReviewInput!" } ]) + addUser(input: AddUserInput!): AddUserPayload! + @variable(subgraph: "Accounts", name: "input", argument: "input") + @resolver(subgraph: "Accounts", select: "{ addUser(input: $input) }", arguments: [ { name: "input", type: "AddUserInput!" } ]) +} + +type Subscription { + onError: Review! + @resolver(subgraph: "Reviews", select: "{ onError }", kind: "SUBSCRIBE") + onNewReview: Review! + @resolver(subgraph: "Reviews", select: "{ onNewReview }", kind: "SUBSCRIBE") +} + +type AddReviewPayload { + review: Review + @source(subgraph: "Reviews") +} + +type AddUserPayload { + user: User + @source(subgraph: "Accounts") +} + +type Product + @variable(subgraph: "Reviews", name: "Product_id", select: "id") + @resolver(subgraph: "Reviews", select: "{ productById(id: $Product_id) }", arguments: [ { name: "Product_id", type: "ID!" } ]) { + id: ID! + @source(subgraph: "Reviews") + reviews: [Review!]! + @source(subgraph: "Reviews") +} + +type Review implements Node + @variable(subgraph: "Reviews", name: "Review_id", select: "id") + @resolver(subgraph: "Reviews", select: "{ reviewById(id: $Review_id) }", arguments: [ { name: "Review_id", type: "ID!" } ]) + @resolver(subgraph: "Reviews", select: "{ nodes(ids: $Review_id) { ... on Review { ... Review } } }", arguments: [ { name: "Review_id", type: "[ID!]!" } ], kind: "BATCH") { + author: User! + @source(subgraph: "Reviews") + body: String! + @source(subgraph: "Reviews") + id: ID! + @source(subgraph: "Reviews") + product: Product! + @source(subgraph: "Reviews") +} + +type SomeData { + accountValue: String! + @source(subgraph: "Accounts") +} + +type User implements Node + @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: "ID!" } ]) + @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[ID!]!" } ], kind: "BATCH") + @resolver(subgraph: "Reviews", select: "{ authorById(id: $User_id) }", arguments: [ { name: "User_id", type: "ID!" } ]) + @resolver(subgraph: "Reviews", select: "{ nodes(ids: $User_id) { ... on User { ... User } } }", arguments: [ { name: "User_id", type: "[ID!]!" } ], kind: "BATCH") { + birthdate: Date! + @source(subgraph: "Accounts") + errorField: String + @source(subgraph: "Accounts") + id: ID! + @source(subgraph: "Accounts") + @source(subgraph: "Reviews") + name: String! + @source(subgraph: "Accounts") + @source(subgraph: "Reviews") + reviews: [Review!]! + @source(subgraph: "Reviews") + username: String! + @source(subgraph: "Accounts") +} + +type Viewer { + data: SomeData! + @source(subgraph: "Accounts") + user: User + @source(subgraph: "Accounts") +} + +"The node interface is implemented by entities that have a global unique identifier." +interface Node { + id: ID! +} + +union ReviewOrAuthor = User | Review + +input AddReviewInput { + authorId: Int! + body: String! + upc: Int! +} + +input AddUserInput { + birthdate: Date! + name: String! + username: String! +} + +"The `Date` scalar represents an ISO-8601 compliant date type." +scalar Date + +"The purpose of the `cost` directive is to define a `weight` for GraphQL types, fields, and arguments. Static analysis can use these weights when calculating the overall cost of a query or response." +directive @cost("The `weight` argument defines what value to add to the overall cost for every appearance, or possible appearance, of a type, field, argument, etc." weight: String!) on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM | INPUT_FIELD_DEFINITION diff --git a/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs b/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs index 645ccc51c96..bf1ad87ccce 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs +++ b/src/HotChocolate/Fusion/test/Core.Tests/DemoIntegrationTests.cs @@ -103,6 +103,58 @@ query GetUser { Assert.Null(result.ExpectOperationResult().Errors); } + [Fact] + public async Task Authors_And_Reviews_Query_GetUserReviews_Report_Cost() + { + // arrange + using var demoProject = await DemoProject.CreateAsync(enableCost: true); + + // act + var fusionGraph = await new FusionGraphComposer(logFactory: _logFactory) + .ComposeAsync( + [ + demoProject.Reviews2.ToConfiguration(Reviews2ExtensionWithCostSdl), + demoProject.Accounts.ToConfiguration(AccountsExtensionWithCostSdl) + ]); + + var executor = await new ServiceCollection() + .AddSingleton(demoProject.HttpClientFactory) + .AddSingleton(demoProject.WebSocketConnectionFactory) + .AddFusionGatewayServer() + .ConfigureFromDocument(SchemaFormatter.FormatAsDocument(fusionGraph)) + .BuildRequestExecutorAsync(); + + var request = Parse( + """ + query GetUser { + users { + name + reviews { + body + author { + name + } + } + } + } + """); + + // act + await using var result = await executor.ExecuteAsync( + OperationRequestBuilder + .New() + .SetDocument(request) + .ReportCost() + .Build()); + + // assert + var snapshot = new Snapshot(); + CollectSnapshotData(snapshot, request, result); + await snapshot.MatchMarkdownAsync(); + + Assert.Null(result.ExpectOperationResult().Errors); + } + [Fact] public async Task Authors_And_Reviews_Query_GetUserReviews_Skip_Author() { diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.md b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.md index c34af4e6935..ae0a87f24c3 100644 --- a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.md +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.md @@ -943,12 +943,12 @@ "fields": null }, { - "name": "ID", + "name": "Int", "kind": "SCALAR", "fields": null }, { - "name": "Int", + "name": "ID", "kind": "SCALAR", "fields": null }, diff --git a/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_GetUserReviews_Report_Cost.md b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_GetUserReviews_Report_Cost.md new file mode 100644 index 00000000000..3e3c08d5265 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Core.Tests/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_Query_GetUserReviews_Report_Cost.md @@ -0,0 +1,129 @@ +# Authors_And_Reviews_Query_GetUserReviews_Report_Cost + +## Result + +```json +{ + "data": { + "users": [ + { + "name": "Ada Lovelace", + "reviews": [ + { + "body": "Love it!", + "author": { + "name": "@ada" + } + }, + { + "body": "Could be better.", + "author": { + "name": "@ada" + } + } + ] + }, + { + "name": "Alan Turing", + "reviews": [ + { + "body": "Too expensive.", + "author": { + "name": "@alan" + } + }, + { + "body": "Prefer something else.", + "author": { + "name": "@alan" + } + } + ] + } + ] + }, + "extensions": { + "operationCost": { + "fieldCost": 12, + "typeCost": 22 + } + } +} +``` + +## Request + +```graphql +query GetUser { + users { + name + reviews { + body + author { + name + } + } + } +} +``` + +## QueryPlan Hash + +```text +8F6E2CCB58DA60498BA9F134BF2F1B8D5C24FBA0 +``` + +## QueryPlan + +```json +{ + "document": "query GetUser { users { name reviews { body author { name } } } }", + "operation": "GetUser", + "rootNode": { + "type": "Sequence", + "nodes": [ + { + "type": "Resolve", + "subgraph": "Accounts", + "document": "query GetUser_1 { users { name __fusion_exports__1: id } }", + "selectionSetId": 0, + "provides": [ + { + "variable": "__fusion_exports__1" + } + ] + }, + { + "type": "Compose", + "selectionSetIds": [ + 0 + ] + }, + { + "type": "ResolveByKeyBatch", + "subgraph": "Reviews2", + "document": "query GetUser_2($__fusion_exports__1: [ID!]!) { nodes(ids: $__fusion_exports__1) { ... on User { reviews { body author { name } } __fusion_exports__1: id } } }", + "selectionSetId": 1, + "path": [ + "nodes" + ], + "requires": [ + { + "variable": "__fusion_exports__1" + } + ] + }, + { + "type": "Compose", + "selectionSetIds": [ + 1 + ] + } + ] + }, + "state": { + "__fusion_exports__1": "User_id" + } +} +``` + diff --git a/src/HotChocolate/Fusion/test/Shared/Accounts/AccountQuery.cs b/src/HotChocolate/Fusion/test/Shared/Accounts/AccountQuery.cs index eac2c01bbab..640a496e00d 100644 --- a/src/HotChocolate/Fusion/test/Shared/Accounts/AccountQuery.cs +++ b/src/HotChocolate/Fusion/test/Shared/Accounts/AccountQuery.cs @@ -1,3 +1,4 @@ +using HotChocolate.CostAnalysis.Types; using HotChocolate.Resolvers; using HotChocolate.Types.Relay; diff --git a/src/HotChocolate/Fusion/test/Shared/DemoProject.cs b/src/HotChocolate/Fusion/test/Shared/DemoProject.cs index a5abf239d40..89b14e0dbb5 100644 --- a/src/HotChocolate/Fusion/test/Shared/DemoProject.cs +++ b/src/HotChocolate/Fusion/test/Shared/DemoProject.cs @@ -82,7 +82,14 @@ private DemoProject( public DemoSubgraph Resale { get; } - public static async Task CreateAsync(CancellationToken ct = default) + public static async Task CreateAsync( + CancellationToken ct = default) + => await CreateAsync(false, ct).ConfigureAwait(false); + + + public static async Task CreateAsync( + bool enableCost, + CancellationToken ct = default) { var disposables = new List(); TestServerFactory testServerFactory = new(); @@ -92,7 +99,8 @@ public static async Task CreateAsync(CancellationToken ct = default s => s .AddRouting() .AddSingleton() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .AddMutationType() .AddSubscriptionType() @@ -115,7 +123,8 @@ public static async Task CreateAsync(CancellationToken ct = default s => s .AddRouting() .AddSingleton() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .AddMutationType() .AddSubscriptionType() @@ -138,7 +147,8 @@ public static async Task CreateAsync(CancellationToken ct = default s => s .AddRouting() .AddSingleton() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .AddMutationType() .AddMutationConventions() @@ -159,7 +169,8 @@ public static async Task CreateAsync(CancellationToken ct = default s => s .AddRouting() .AddSingleton() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .AddMutationType() .AddGlobalObjectIdentification() @@ -180,7 +191,8 @@ public static async Task CreateAsync(CancellationToken ct = default var shipping = testServerFactory.Create( s => s .AddRouting() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .ConfigureSchema(b => b.SetContextData(GlobalIdSupportEnabled, 1)) .AddConvention(_ => new DefaultNamingConventions()), @@ -198,7 +210,8 @@ public static async Task CreateAsync(CancellationToken ct = default var shipping2 = testServerFactory.Create( s => s .AddRouting() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .ConfigureSchema(b => b.SetContextData(GlobalIdSupportEnabled, 1)) .AddConvention(_ => new DefaultNamingConventions()), @@ -216,7 +229,8 @@ public static async Task CreateAsync(CancellationToken ct = default var appointment = testServerFactory.Create( s => s .AddRouting() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .AddObjectType() .AddObjectType() @@ -236,7 +250,8 @@ public static async Task CreateAsync(CancellationToken ct = default var patient1 = testServerFactory.Create( s => s .AddRouting() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .AddGlobalObjectIdentification() .AddConvention(_ => new DefaultNamingConventions()), @@ -254,7 +269,8 @@ public static async Task CreateAsync(CancellationToken ct = default var books = testServerFactory.Create( s => s .AddRouting() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .AddConvention(_ => new DefaultNamingConventions()), c => c @@ -271,7 +287,8 @@ public static async Task CreateAsync(CancellationToken ct = default var authors = testServerFactory.Create( s => s .AddRouting() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .AddConvention(_ => new DefaultNamingConventions()), c => c @@ -288,7 +305,8 @@ public static async Task CreateAsync(CancellationToken ct = default var resale = testServerFactory.Create( s => s .AddRouting() - .AddGraphQLServer(disableDefaultSecurity: true) + .AddGraphQLServer(disableDefaultSecurity: !enableCost) + .DisableIntrospection(false) .AddQueryType() .AddGlobalObjectIdentification() .AddMutationConventions() diff --git a/src/HotChocolate/Fusion/test/Shared/DemoProjectSchemaExtensions.cs b/src/HotChocolate/Fusion/test/Shared/DemoProjectSchemaExtensions.cs index 295509aa216..845d755f053 100644 --- a/src/HotChocolate/Fusion/test/Shared/DemoProjectSchemaExtensions.cs +++ b/src/HotChocolate/Fusion/test/Shared/DemoProjectSchemaExtensions.cs @@ -10,6 +10,14 @@ extend type Query { } """; + public const string AccountsExtensionWithCostSdl = + """ + extend type Query { + userById(id: ID! @is(field: "id")): User! @cost(weight: "1.0") + usersById(ids: [ID!]! @is(field: "id")): [User!]! + } + """; + public const string AccountsExtensionWithTagSdl = """ extend type Query { @@ -46,6 +54,19 @@ extend type Query { } """; + public const string ReviewsExtensionWithCostSdl = + """ + extend type Query { + authorById(id: ID! @is(field: "id")): Author @cost(weight: "2.0") + productById(id: ID! @is(field: "id")): Product + } + + schema + @rename(coordinate: "Query.authorById", newName: "userById") + @rename(coordinate: "Author", newName: "User") { + } + """; + public const string ReviewsExtensionWithTagSdl = """ extend type Query { @@ -72,6 +93,29 @@ extend type Query { } """; + public const string Reviews2ExtensionWithCostSdl = + """ + extend type Query { + authorById(id: ID! @is(field: "id")): User @cost(weight: "2.0") + productById(id: ID! @is(field: "id")): Product @cost(weight: "1.0") + } + + extend type User { + reviews: [Review!]! @listSize(assumedSize: 10) + } + + schema + @rename(coordinate: "Query.authorById", newName: "userById") { + } + + directive @listSize( + assumedSize: Int + slicingArguments: [String!] + slicingArgumentDefaultValue: Int + sizedFields: [String!] + requireOneSlicingArgument: Boolean! = true) on FIELD_DEFINITION + """; + public const string ProductsExtensionSdl = """ extend type Query { diff --git a/src/HotChocolate/Fusion/test/Shared/Reviews/ReviewsQuery.cs b/src/HotChocolate/Fusion/test/Shared/Reviews/ReviewsQuery.cs index f7ebfc499ae..dcf083cca56 100644 --- a/src/HotChocolate/Fusion/test/Shared/Reviews/ReviewsQuery.cs +++ b/src/HotChocolate/Fusion/test/Shared/Reviews/ReviewsQuery.cs @@ -1,3 +1,4 @@ +using HotChocolate.CostAnalysis.Types; using HotChocolate.Types.Relay; namespace HotChocolate.Fusion.Shared.Reviews; diff --git a/src/HotChocolate/Skimmed/src/Skimmed/DirectiveDefinition.cs b/src/HotChocolate/Skimmed/src/Skimmed/DirectiveDefinition.cs index 604c28c0edb..c2416810d49 100644 --- a/src/HotChocolate/Skimmed/src/Skimmed/DirectiveDefinition.cs +++ b/src/HotChocolate/Skimmed/src/Skimmed/DirectiveDefinition.cs @@ -8,13 +8,13 @@ namespace HotChocolate.Skimmed; /// /// Represents a GraphQL directive definition. /// -public class DirectiveDefinition(string name) +public class DirectiveDefinition : INamedTypeSystemMemberDefinition , IDescriptionProvider , IFeatureProvider , ISealable { - private string _name = name.EnsureGraphQLName(); + private string _name; private IInputFieldDefinitionCollection? _arguments; private IFeatureCollection? _features; private string? _description; @@ -23,6 +23,14 @@ public class DirectiveDefinition(string name) private DirectiveLocation _locations; private bool _isReadOnly; + /// + /// Represents a GraphQL directive definition. + /// + public DirectiveDefinition(string name) + { + _name = name.EnsureGraphQLName(); + } + /// /// Gets or sets the name of the directive. ///