diff --git a/src/HotChocolate/CostAnalysis/src/CostAnalysis/Types/ListSizeAttribute.cs b/src/HotChocolate/CostAnalysis/src/CostAnalysis/Types/ListSizeAttribute.cs index d42b9fa56ce..78406457e75 100644 --- a/src/HotChocolate/CostAnalysis/src/CostAnalysis/Types/ListSizeAttribute.cs +++ b/src/HotChocolate/CostAnalysis/src/CostAnalysis/Types/ListSizeAttribute.cs @@ -13,14 +13,23 @@ namespace HotChocolate.CostAnalysis.Types; public sealed class ListSizeAttribute : ObjectFieldDescriptorAttribute { private readonly int? _assumedSize; + private readonly int? _slicingArgumentDefaultValue; /// /// The maximum length of the list returned by this field. + /// Must be a non-negative integer when specified. + /// This property is intended to be set via attribute initialization only; + /// reading the value at runtime is not supported. /// + /// Thrown when attempting to read this property at runtime. public int AssumedSize { - get => _assumedSize ?? 0; - init => _assumedSize = value; + get => throw new NotSupportedException(); + init + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + _assumedSize = value; + } } /// @@ -31,9 +40,20 @@ public int AssumedSize /// /// The default value for a slicing argument, which is used if the argument is not present in a - /// query. + /// query. Must be a non-negative integer when specified. + /// This property is intended to be set via attribute initialization only; + /// reading the value at runtime is not supported. /// - public int? SlicingArgumentDefaultValue { get; init; } + /// Thrown when attempting to read this property at runtime. + public int SlicingArgumentDefaultValue + { + get => throw new NotSupportedException(); + init + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + _slicingArgumentDefaultValue = value; + } + } /// /// The subfield(s) that the list size applies to. @@ -56,6 +76,7 @@ protected override void OnConfigure( _assumedSize, SlicingArguments?.ToImmutableArray(), SizedFields?.ToImmutableArray(), - RequireOneSlicingArgument)); + RequireOneSlicingArgument, + _slicingArgumentDefaultValue)); } } diff --git a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/AttributeTests.cs b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/AttributeTests.cs index 83ea93c8f53..b41295dfcd8 100644 --- a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/AttributeTests.cs +++ b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/AttributeTests.cs @@ -107,6 +107,26 @@ public void ListSize_ObjectFieldAttribute_AppliesDirective() // assert Assert.Equal(10, costDirective.AssumedSize); + Assert.Equal(42, costDirective.SlicingArgumentDefaultValue); + Assert.Equal(["first", "last"], costDirective.SlicingArguments, StringComparer.Ordinal); + Assert.Equal(["edges", "nodes"], costDirective.SizedFields, StringComparer.Ordinal); + Assert.False(costDirective.RequireOneSlicingArgument); + } + + [Fact] + public void ListSize_ObjectFieldAttribute_AppliesDirective_NoExplicitSizes() + { + // arrange & act + var query = CreateSchema().Types.GetType(OperationTypeNames.Query); + + var costDirective = query.Fields["examples2"] + .Directives + .Single(d => d.Type.Name == "listSize") + .ToValue(); + + // assert + Assert.Null(costDirective.AssumedSize); + Assert.Null(costDirective.SlicingArgumentDefaultValue); Assert.Equal(["first", "last"], costDirective.SlicingArguments, StringComparer.Ordinal); Assert.Equal(["edges", "nodes"], costDirective.SizedFields, StringComparer.Ordinal); Assert.False(costDirective.RequireOneSlicingArgument); @@ -132,13 +152,25 @@ private static class Queries AssumedSize = 10, SlicingArguments = ["first", "last"], SizedFields = ["edges", "nodes"], - RequireOneSlicingArgument = false)] + RequireOneSlicingArgument = false, + SlicingArgumentDefaultValue = 42)] [Cost(5.0)] // ReSharper disable once UnusedMember.Local public static List GetExamples([Cost(8.0)] ExampleInput _) { return [new Example(ExampleEnum.Member)]; } + + [ListSize( + SlicingArguments = ["first", "last"], + SizedFields = ["edges", "nodes"], + RequireOneSlicingArgument = false)] + [Cost(5.0)] + // ReSharper disable once UnusedMember.Local + public static List GetExamples2([Cost(8.0)] ExampleInput _) + { + return [new Example(ExampleEnum.Member)]; + } } [ObjectType] diff --git a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/DescriptorExtensionTests.cs b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/DescriptorExtensionTests.cs index fa8685d6d96..22cfed13843 100644 --- a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/DescriptorExtensionTests.cs +++ b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/DescriptorExtensionTests.cs @@ -143,7 +143,8 @@ public void ListSize_ObjectFieldDescriptor_AppliesDirective() assumedSize: 10, slicingArguments: ["first", "last"], sizedFields: ["edges", "nodes"], - requireOneSlicingArgument: false)) + requireOneSlicingArgument: false, + slicingArgumentDefaultValue: 42)) .AddDirectiveType() .Use(next => next) .Create(); @@ -154,6 +155,7 @@ public void ListSize_ObjectFieldDescriptor_AppliesDirective() // assert Assert.Equal(10, listSizeDirective.AssumedSize); + Assert.Equal(42, listSizeDirective.SlicingArgumentDefaultValue); Assert.Equal(["first", "last"], listSizeDirective.SlicingArguments, StringComparer.Ordinal); Assert.Equal(["edges", "nodes"], listSizeDirective.SizedFields, StringComparer.Ordinal); Assert.False(listSizeDirective.RequireOneSlicingArgument); diff --git a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/SlicingArgumentsTests.cs b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/SlicingArgumentsTests.cs index 1274d1a767c..64585d24437 100644 --- a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/SlicingArgumentsTests.cs +++ b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/SlicingArgumentsTests.cs @@ -1,3 +1,4 @@ +using HotChocolate.CostAnalysis.Types; using HotChocolate.Execution; using HotChocolate.Types; using Microsoft.Extensions.DependencyInjection; @@ -249,9 +250,51 @@ public async Task SlicingArguments_First_Is_Variable_And_Last_Is_Variable() """); } + [Fact] + public async Task SlicingArgumentDefaultValue_Inferred_From_DefaultPageSize() + { + var schema = + await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType() + .BuildSchemaAsync(); + + schema.MatchSnapshot(); + } + + [Fact] + public async Task SlicingArgumentDefaultValue_ListSizeAttribute_HasPrecedenceOver_DefaultPageSize() + { + var schema = + await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType() + .BuildSchemaAsync(); + + schema.MatchSnapshot(); + } + public class Query { [UsePaging] public IEnumerable GetFoos() => Enumerable.Range(1, 100); } + + public class Query2 + { + [UsePaging(DefaultPageSize = 42)] + public IEnumerable GetFoos() => Enumerable.Range(1, 100); + } + + public class Query3 + { + [UsePaging(DefaultPageSize = 42)] + [ListSize( + AssumedSize = 10, + SlicingArguments = ["first", "last"], + SizedFields = ["edges", "nodes"], + RequireOneSlicingArgument = false, + SlicingArgumentDefaultValue = 999)] + public IEnumerable GetFoos() => Enumerable.Range(1, 100); + } } diff --git a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/StaticQueryAnalysisTests.cs b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/StaticQueryAnalysisTests.cs index 877171afbc7..638dc6fe71d 100644 --- a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/StaticQueryAnalysisTests.cs +++ b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/StaticQueryAnalysisTests.cs @@ -218,6 +218,21 @@ public static TheoryData ListQueryData() ) """, "examples { field1, field2 }" + }, + // @listSize directive with slicing arguments and slicing argument default value. + // (no limit in query, with assumedSize, slicingArgumentDefaultValue: 42 and requireOneSlicingArgument: false). + { + 9, + """ + examples(limit: Int): [Example!]! + @listSize( + slicingArguments: ["limit"], + assumedSize: 10, + requireOneSlicingArgument: false, + slicingArgumentDefaultValue: 42 + ) + """, + "examples { field1, field2 }" } }; } diff --git a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/__snapshots__/SlicingArgumentsTests.SlicingArgumentDefaultValue_Inferred_From_DefaultPageSize.graphql b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/__snapshots__/SlicingArgumentsTests.SlicingArgumentDefaultValue_Inferred_From_DefaultPageSize.graphql new file mode 100644 index 00000000000..b7ee2893b25 --- /dev/null +++ b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/__snapshots__/SlicingArgumentsTests.SlicingArgumentDefaultValue_Inferred_From_DefaultPageSize.graphql @@ -0,0 +1,40 @@ +schema { + query: Query2 +} + +"A connection to a list of items." +type FoosConnection { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges." + edges: [FoosEdge!] + "A flattened list of the nodes." + nodes: [Int!] +} + +"An edge in a connection." +type FoosEdge { + "A cursor for use in pagination." + cursor: String! + "The item at the end of the edge." + node: Int! +} + +"Information about pagination in a connection." +type PageInfo { + "Indicates whether more edges exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more edges exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String + "When paginating forwards, the cursor to continue." + endCursor: String +} + +type Query2 { + foos("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): FoosConnection @listSize(assumedSize: 50, slicingArguments: ["first", "last"], slicingArgumentDefaultValue: 42, sizedFields: ["edges", "nodes"], requireOneSlicingArgument: false) +} + +"The purpose of the `@listSize` directive is to either inform the static analysis about the size of returned lists (if that information is statically available), or to point the analysis to where to find that information." +directive @listSize("The `assumedSize` argument can be used to statically define the maximum length of a list returned by a field." assumedSize: Int "The `slicingArguments` argument can be used to define which of the field's arguments with numeric type are slicing arguments, so that their value determines the size of the list returned by that field. It may specify a list of multiple slicing arguments." slicingArguments: [String!] "The `slicingArgumentDefaultValue` argument can be used to define a default value for a slicing argument, which is used if the argument is not present in a query." slicingArgumentDefaultValue: Int "The `sizedFields` argument can be used to define that the value of the `assumedSize` argument or of a slicing argument does not affect the size of a list returned by a field itself, but that of a list returned by one of its sub-fields." sizedFields: [String!] "The `requireOneSlicingArgument` argument can be used to inform the static analysis that it should expect that exactly one of the defined slicing arguments is present in a query. If that is not the case (i.e., if none or multiple slicing arguments are present), the static analysis may throw an error." requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION diff --git a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/__snapshots__/SlicingArgumentsTests.SlicingArgumentDefaultValue_ListSizeAttribute_HasPrecedenceOver_DefaultPageSize.graphql b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/__snapshots__/SlicingArgumentsTests.SlicingArgumentDefaultValue_ListSizeAttribute_HasPrecedenceOver_DefaultPageSize.graphql new file mode 100644 index 00000000000..0a517e20a87 --- /dev/null +++ b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/__snapshots__/SlicingArgumentsTests.SlicingArgumentDefaultValue_ListSizeAttribute_HasPrecedenceOver_DefaultPageSize.graphql @@ -0,0 +1,40 @@ +schema { + query: Query3 +} + +"A connection to a list of items." +type FoosConnection { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges." + edges: [FoosEdge!] + "A flattened list of the nodes." + nodes: [Int!] +} + +"An edge in a connection." +type FoosEdge { + "A cursor for use in pagination." + cursor: String! + "The item at the end of the edge." + node: Int! +} + +"Information about pagination in a connection." +type PageInfo { + "Indicates whether more edges exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more edges exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String + "When paginating forwards, the cursor to continue." + endCursor: String +} + +type Query3 { + foos("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): FoosConnection @listSize(assumedSize: 10, slicingArguments: ["first", "last"], slicingArgumentDefaultValue: 999, sizedFields: ["edges", "nodes"], requireOneSlicingArgument: false) +} + +"The purpose of the `@listSize` directive is to either inform the static analysis about the size of returned lists (if that information is statically available), or to point the analysis to where to find that information." +directive @listSize("The `assumedSize` argument can be used to statically define the maximum length of a list returned by a field." assumedSize: Int "The `slicingArguments` argument can be used to define which of the field's arguments with numeric type are slicing arguments, so that their value determines the size of the list returned by that field. It may specify a list of multiple slicing arguments." slicingArguments: [String!] "The `slicingArgumentDefaultValue` argument can be used to define a default value for a slicing argument, which is used if the argument is not present in a query." slicingArgumentDefaultValue: Int "The `sizedFields` argument can be used to define that the value of the `assumedSize` argument or of a slicing argument does not affect the size of a list returned by a field itself, but that of a list returned by one of its sub-fields." sizedFields: [String!] "The `requireOneSlicingArgument` argument can be used to inform the static analysis that it should expect that exactly one of the defined slicing arguments is present in a query. If that is not the case (i.e., if none or multiple slicing arguments are present), the static analysis may throw an error." requireOneSlicingArgument: Boolean = true) on FIELD_DEFINITION diff --git a/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/__snapshots__/StaticQueryAnalysisTests.Execute_ListQuery_ReturnsExpectedResult_9.md b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/__snapshots__/StaticQueryAnalysisTests.Execute_ListQuery_ReturnsExpectedResult_9.md new file mode 100644 index 00000000000..cafd10e042e --- /dev/null +++ b/src/HotChocolate/CostAnalysis/test/CostAnalysis.Tests/__snapshots__/StaticQueryAnalysisTests.Execute_ListQuery_ReturnsExpectedResult_9.md @@ -0,0 +1,52 @@ +# Execute_ListQuery_ReturnsExpectedResult + +## Query + +```graphql +{ + examples { + field1 + field2 + } +} +``` + +## Result + +```text +{ + "data": { + "examples": [ + { + "field1": true, + "field2": 1 + } + ] + }, + "extensions": { + "operationCost": { + "fieldCost": 1, + "typeCost": 43 + } + } +} +``` + +## Schema + +```text +type Query { + examples(limit: Int): [Example!]! + @listSize( + slicingArguments: ["limit"], + assumedSize: 10, + requireOneSlicingArgument: false, + slicingArgumentDefaultValue: 42 + ) +} + +type Example { + field1: Boolean! + field2: Int! +} +```