diff --git a/execution/engine/execution_engine_cost_test.go b/execution/engine/execution_engine_cost_test.go new file mode 100644 index 0000000000..1e53d8a7a7 --- /dev/null +++ b/execution/engine/execution_engine_cost_test.go @@ -0,0 +1,1953 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/execution/graphql" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" +) + +func TestExecutionEngine_Cost(t *testing.T) { + + t.Run("common on star wars scheme", func(t *testing.T) { + rootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"hero", "droid"}}, + {TypeName: "Human", FieldNames: []string{"name", "height", "friends"}}, + {TypeName: "Droid", FieldNames: []string{"name", "primaryFunction", "friends"}}, + } + childNodes := []plan.TypeField{ + {TypeName: "Character", FieldNames: []string{"name", "friends"}}, + } + customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig( + t, + nil, + string(graphql.StarwarsSchema(t).RawSchema()), + ), + }) + + t.Run("droid with weighted plain fields", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + droid(id: "R2D2") { + name + primaryFunction + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, + }, + }}, + customConfig, + ), + }, + fields: []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "droid", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "id", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + }, + }, + }, + expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + expectedEstimatedCost: 18, // Query.droid (1) + droid.name (17) + }, + computeCosts(), + )) + + t.Run("droid with weighted plain fields and an argument", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + droid(id: "R2D2") { + name + primaryFunction + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Query", FieldName: "droid"}: { + ArgumentWeights: map[string]int{"id": 3}, + HasWeight: false, + }, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, + }, + }}, + customConfig, + ), + }, + fields: []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "droid", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "id", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + }, + }, + }, + expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + expectedEstimatedCost: 21, // Query.droid (1) + Query.droid.id (3) + droid.name (17) + }, + computeCosts(), + )) + + t.Run("negative weights - cost is never negative", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + droid(id: "R2D2") { + name + primaryFunction + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Query", FieldName: "droid"}: { + HasWeight: true, + Weight: -10, // Negative field weight + ArgumentWeights: map[string]int{"id": -5}, // Negative argument weight + }, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: -3}, + {TypeName: "Droid", FieldName: "primaryFunction"}: {HasWeight: true, Weight: -2}, + }, + Types: map[string]int{ + "Droid": -1, // Negative type weight + }, + }}, + customConfig, + ), + }, + fields: []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "droid", + Arguments: []plan.ArgumentConfiguration{ + { + Name: "id", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + }, + }, + }, + expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, + // All weights are negative. + // But cost should be floored to 0 (never negative) + expectedEstimatedCost: 0, + }, + computeCosts(), + )) + + t.Run("hero field has weight (returns interface) and with concrete fragment", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + name + ... on Human { height } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke Skywalker","height":"12"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{RootNodes: rootNodes, ChildNodes: childNodes, CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 3}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 7}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, + }, + Types: map[string]int{ + "Human": 13, + }, + }}, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"name":"Luke Skywalker","height":"12"}}}`, + expectedEstimatedCost: 22, // Query.hero (2) + Human.height (3) + Droid.name (17=max(7, 17)) + }, + computeCosts(), + )) + + t.Run("hero field has no weight (returns interface) and with concrete fragment", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { name } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke Skywalker"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{RootNodes: rootNodes, ChildNodes: childNodes, CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 7}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, + }, + Types: map[string]int{ + "Human": 13, + "Droid": 11, + }, + }}, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"name":"Luke Skywalker"}}}`, + expectedEstimatedCost: 30, // Query.Human (13) + Droid.name (17=max(7, 17)) + }, + computeCosts(), + )) + + t.Run("query hero without assumedSize on friends", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name primaryFunction } + ...on Human { name height } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ + {"__typename":"Human","name":"Luke Skywalker","height":"12"}, + {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} + ]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, + }, + Types: map[string]int{ + "Human": 7, + "Droid": 5, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, + expectedEstimatedCost: 127, // Query.hero(max(7,5))+10*(Human(max(7,5))+Human.name(2)+Human.height(1)+Droid.name(2)) + }, + computeCosts(), + )) + + t.Run("query hero with assumedSize on friends", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name primaryFunction } + ...on Human { name height } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ + {"__typename":"Human","name":"Luke Skywalker","height":"12"}, + {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} + ]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 5}, + {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 20}, + }, + Types: map[string]int{ + "Human": 7, + "Droid": 5, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, + expectedEstimatedCost: 247, // Query.hero(max(7,5))+ 20 * (7+2+2+1) + // We pick maximum on every path independently. This is to reveal the upper boundary. + // Query.hero: picked maximum weight (Human=7) out of two types (Human, Droid) + // Query.hero.friends: the max possible weight (7) is for implementing class Human + // of the returned type of Character; the multiplier picked for the Droid since + // it is the maximum possible value - we considered the enclosing type that contains it. + }, + computeCosts(), + )) + + t.Run("query hero with assumedSize on friends and weight defined", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name primaryFunction } + ...on Human { name height } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ + {"__typename":"Human","name":"Luke Skywalker","height":"12"}, + {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} + ]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "friends"}: {HasWeight: true, Weight: 3}, + {TypeName: "Droid", FieldName: "friends"}: {HasWeight: true, Weight: 4}, + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 5}, + {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 20}, + }, + Types: map[string]int{ + "Human": 7, + "Droid": 5, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, + expectedEstimatedCost: 187, // Query.hero(max(7,5))+ 20 * (4+2+2+1) + }, + computeCosts(), + )) + + t.Run("query hero with empty cost structures", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name primaryFunction } + ...on Human { name height } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ + {"__typename":"Human","name":"Luke Skywalker","height":"12"}, + {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} + ]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{}, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, + expectedEstimatedCost: 11, // Query.hero(max(1,1))+ 10 * 1 + }, + computeCosts(), + )) + + // Actual cost tests - verifies that actual cost uses real list sizes from response + // rather than estimated/assumed sizes + + t.Run("actual cost with list field - 2 items instead of default 10", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name primaryFunction } + ...on Human { name height } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + // Response has 2 friends (not 10 as estimated) + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ + {"__typename":"Human","name":"Luke Skywalker","height":"12"}, + {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} + ]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, + }, + Types: map[string]int{ + "Human": 7, + "Droid": 5, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, + // Estimated with default list size 10: hero(7) + 10 * (7 + 2 + 2 + 1) = 127 + expectedEstimatedCost: 127, + // Actual uses real list size 2: hero(7) + 2 * (7 + 2 + 2 + 1) = 31 + expectedActualCost: 31, + }, + computeCosts(), + )) + + t.Run("actual cost with empty list", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + hero { + friends { + ...on Droid { name } + ...on Human { name } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + // Response has empty friends array + sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, + }, + Types: map[string]int{ + "Human": 7, + "Droid": 5, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"friends":[]}}}`, + // Estimated with default list size 10: hero(7) + 10 * (7 + 2 + 2) = 117 + expectedEstimatedCost: 117, + // Actual with empty list: hero(7) + 1 * (7 + 2 + 2) = 18 + // We consider empty lists as lists containing one item to account for the + // resolver work. + expectedActualCost: 18, + }, + computeCosts(), + )) + + t.Run("named fragment on interface", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: ` + fragment CharacterFields on Character { + name + friends { name } + } + { hero { ...CharacterFields } } + `, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke","friends":[{"name":"Leia"}]}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 3}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 5}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 4}, + {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 6}, + }, + Types: map[string]int{ + "Human": 2, + "Droid": 3, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"name":"Luke","friends":[{"name":"Leia"}]}}}`, + // Cost calculation: + // Query.hero: 2 + // Character.name: max(Human.name=3, Droid.name=5) = 5 + // friends listSize: max(4, 6) = 6 + // Character type: max(Human=2, Droid=3) = 3 + // name: max(Human.name=3, Droid.name=5) = 5 + // Total: 2 + 5 + 6 * (3 + 5) + expectedEstimatedCost: 55, + }, + computeCosts(), + )) + + t.Run("named fragment with concrete type", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: ` + fragment HumanFields on Human { + name + height + } + { hero { ...HumanFields } } + `, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke","height":"1.72"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, + {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 3}, + {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 7}, + {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 5}, + }, + Types: map[string]int{ + "Human": 1, + "Droid": 1, + }, + }, + }, + customConfig, + ), + }, + expectedResponse: `{"data":{"hero":{"name":"Luke","height":"1.72"}}}`, + // Total: 2 + 3 + 7 + expectedEstimatedCost: 12, + }, + computeCosts(), + )) + + }) + + t.Run("union types", func(t *testing.T) { + unionSchema := ` + type Query { + search(term: String!): [SearchResult!] + } + union SearchResult = User | Post | Comment + type User @key(fields: "id") { + id: ID! + name: String! + email: String! + } + type Post @key(fields: "id") { + id: ID! + title: String! + body: String! + } + type Comment @key(fields: "id") { + id: ID! + text: String! + } + ` + schema, err := graphql.NewSchemaFromString(unionSchema) + require.NoError(t, err) + + rootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"search"}}, + {TypeName: "User", FieldNames: []string{"id", "name", "email"}}, + {TypeName: "Post", FieldNames: []string{"id", "title", "body"}}, + {TypeName: "Comment", FieldNames: []string{"id", "text"}}, + } + childNodes := []plan.TypeField{} + customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, unionSchema), + }) + fieldConfig := []plan.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "search", + Path: []string{"search"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "term", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + } + + t.Run("union with all member types", runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + search(term: "test") { + ... on User { name email } + ... on Post { title body } + ... on Comment { text } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"search":[{"__typename":"User","name":"John","email":"john@test.com"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "User", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "User", FieldName: "email"}: {HasWeight: true, Weight: 3}, + {TypeName: "Post", FieldName: "title"}: {HasWeight: true, Weight: 4}, + {TypeName: "Post", FieldName: "body"}: {HasWeight: true, Weight: 5}, + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: {AssumedSize: 5}, + }, + Types: map[string]int{ + "User": 2, + "Post": 3, + "Comment": 1, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"name":"John","email":"john@test.com"}]}}`, + // search listSize: 10 + // For each SearchResult, use max across all union members: + // Type weight: max(User=2, Post=3, Comment=1) = 3 + // Fields: all fields from all fragments are counted + // (2 + 3) + (4 + 5) + (1) = 15 + // TODO: this is not correct, we should pick a maximum sum among types implementing union. + // 9 should be used instead of 15 + // Total: 5 * (3 + 15) + expectedEstimatedCost: 90, + }, + computeCosts(), + )) + + t.Run("union with weighted search field", runWithoutError( + ExecutionEngineTestCase{ + schema: schema, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + search(term: "test") { + ... on User { name } + ... on Post { title } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"search":[{"__typename":"User","name":"John"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "User", FieldName: "name"}: {HasWeight: true, Weight: 2}, + {TypeName: "Post", FieldName: "title"}: {HasWeight: true, Weight: 5}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "search"}: {AssumedSize: 3}, + }, + Types: map[string]int{ + "User": 6, + "Post": 10, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"search":[{"name":"John"}]}}`, + // Query.search: max(User=10, Post=6) + // search listSize: 3 + // Union members: + // All fields from all fragments: User.name(2) + Post.title(5) + // Total: 3 * (10+2+5) + // TODO: we might correct this by counting only members of one implementing types + // of a union when fragments are used. + expectedEstimatedCost: 51, + }, + computeCosts(), + )) + }) + + t.Run("listSize", func(t *testing.T) { + listSchema := ` + type Query { + items(first: Int, last: Int): [Item!] + } + type Item @key(fields: "id") { + id: ID + } + ` + schemaSlicing, err := graphql.NewSchemaFromString(listSchema) + require.NoError(t, err) + rootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"items"}}, + {TypeName: "Item", FieldNames: []string{"id"}}, + } + childNodes := []plan.TypeField{} + customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, listSchema), + }) + fieldConfig := []plan.FieldConfiguration{ + { + TypeName: "Query", + FieldName: "items", + Path: []string{"items"}, + Arguments: []plan.ArgumentConfiguration{ + { + Name: "first", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + { + Name: "last", + SourceType: plan.FieldArgumentSource, + RenderConfig: plan.RenderArgumentAsGraphQLValue, + }, + }, + }, + } + t.Run("multiple slicing arguments as literals", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query MultipleSlicingArguments { + items(first: 5, last: 12) { id } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[ {"id":"2"}, {"id":"3"} ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 8, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{ + "Item": 3, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: 48, // slicingArgument(12) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + t.Run("slicing argument as a variable", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query SlicingWithVariable($limit: Int!) { + items(first: $limit) { id } + }`, + Variables: []byte(`{"limit": 25}`), + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[ {"id":"2"}, {"id":"3"} ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 8, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{ + "Item": 3, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"2"},{"id":"3"}]}}`, + expectedEstimatedCost: 100, // slicingArgument($limit=25) * (Item(3)+Item.id(1)) + }, + computeCosts(), + )) + t.Run("slicing argument not provided falls back to assumedSize", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NoSlicingArg { + items { id } + }`, + // No slicing arguments provided - should fall back to assumedSize + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[{"id":"1"},{"id":"2"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 15, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{ + "Item": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[{"id":"1"},{"id":"2"}]}}`, + expectedEstimatedCost: 45, // Total: 15 * (2 + 1) + }, + computeCosts(), + )) + t.Run("zero slicing argument falls back to assumedSize", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query ZeroSlicing { + items(first: 0) { id } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 20, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{ + "Item": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[]}}`, + expectedEstimatedCost: 60, // 20 * (2 + 1) + }, + computeCosts(), + )) + t.Run("negative slicing argument falls back to assumedSize", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaSlicing, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `query NegativeSlicing { + items(first: -5) { id } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + sendResponseBody: `{"data":{"items":[]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "items"}: { + AssumedSize: 25, + SlicingArguments: []string{"first", "last"}, + }, + }, + Types: map[string]int{ + "Item": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"items":[]}}`, + expectedEstimatedCost: 75, // 25 * (2 + 1) + }, + computeCosts(), + )) + + }) + + t.Run("nested lists with compounding multipliers", func(t *testing.T) { + nestedSchema := ` + type Query { + users(first: Int): [User!] + } + type User @key(fields: "id") { + id: ID! + posts(first: Int): [Post!] + } + type Post @key(fields: "id") { + id: ID! + comments(first: Int): [Comment!] + } + type Comment @key(fields: "id") { + id: ID! + text: String! + } + ` + schemaNested, err := graphql.NewSchemaFromString(nestedSchema) + require.NoError(t, err) + + rootNodes := []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"users"}}, + {TypeName: "User", FieldNames: []string{"id", "posts"}}, + {TypeName: "Post", FieldNames: []string{"id", "comments"}}, + {TypeName: "Comment", FieldNames: []string{"id", "text"}}, + } + childNodes := []plan.TypeField{} + customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, nestedSchema), + }) + fieldConfig := []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "users", Path: []string{"users"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "User", FieldName: "posts", Path: []string{"posts"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Post", FieldName: "comments", Path: []string{"comments"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + } + + t.Run("nested lists with slicing arguments", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 10) { + posts(first: 5) { + comments(first: 3) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, + // Cost calculation: + // users(first:10): multiplier 10 + // User type weight: 4 + // posts(first:5): multiplier 5 + // Post type weight: 3 + // comments(first:3): multiplier 3 + // Comment type weight: 2 + // text weight: 1 + // Total: 10 * (4 + 5 * (3 + 3 * (2 + 1))) + expectedEstimatedCost: 640, + }, + computeCosts(), + )) + + t.Run("nested lists fallback to assumedSize when slicing arg not provided", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 2) { + posts { + comments(first: 4) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hi"}]}]}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, // no slicing arg, should use this + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hi"}]}]}]}}`, + // Cost calculation: + // users(first:2): multiplier 2 + // User type weight: 4 + // posts (no arg): assumedSize 50 + // Post type weight: 3 + // comments(first:4): multiplier 4 + // Comment type weight: 2 + // text weight: 1 + // Total: 2 * (4 + 50 * (3 + 4 * (2 + 1))) + expectedEstimatedCost: 1508, + }, + computeCosts(), + )) + + t.Run("actual cost for nested lists - 1 item at each level", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 10) { + posts(first: 5) { + comments(first: 3) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + // Response has 1 user with 1 post with 1 comment + sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, + // Estimated cost with slicing arguments (10, 5, 3): + // Total: 10 * (4 + 5 * (3 + 3 * (2 + 1))) = 640 + expectedEstimatedCost: 640, + // Actual cost with 1 item at each level: + // Total: 1 * (4 + 1 * (3 + 1 * (2 + 1))) = 10 + expectedActualCost: 10, + }, + computeCosts(), + )) + + t.Run("actual cost for nested lists - varying sizes", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 10) { + posts(first: 5) { + comments(first: 3) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + // Response has 2 users, each with 2 posts, each with 3 comments + sendResponseBody: `{"data":{"users":[ + {"posts":[ + {"comments":[{"text":"a"},{"text":"b"},{"text":"c"}]}, + {"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]}, + {"posts":[ + {"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]}, + {"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"a"},{"text":"b"},{"text":"c"}]},{"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]},{"posts":[{"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]},{"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, + expectedEstimatedCost: 640, + // Actual cost: 2 * (4 + 2 * (3 + 3 * (2 + 1))) = 56 + expectedActualCost: 56, + }, + computeCosts(), + )) + + t.Run("actual cost for nested lists - uneven sizes", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 10) { + posts(first: 5) { + comments(first: 2) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + // Response has 2 users, with 1.5 posts each, each with 3 comments + sendResponseBody: `{"data":{"users":[ + {"posts":[ + {"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]}, + {"posts":[ + {"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]}, + {"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]},{"posts":[{"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]},{"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, + // Estimated : 10 * (4 + 5 * (3 + 2 * (2 + 1))) = 490 + expectedEstimatedCost: 490, + // Actual cost: 2 * (4 + 1.5 * (3 + 3 * (2 + 1))) = 44 + expectedActualCost: 44, + }, + computeCosts(), + )) + + t.Run("actual cost for root-level list - no parent", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ users(first: 10) { id } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + // Response has 3 users at the root level + sendResponseBody: `{"data":{"users":[ + {"id":"1"}, + {"id":"2"}, + {"id":"3"}]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "User", FieldName: "id"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"id":"1"},{"id":"2"},{"id":"3"}]}}`, + // Estimated: 10 * (4 + 1) = 50 + expectedEstimatedCost: 50, + // Actual cost: 3 users at root + // 3 * (4 + 1) = 15 + expectedActualCost: 15, + }, + computeCosts(), + )) + + t.Run("mixed empty and non-empty lists - averaging behavior", runWithoutError( + ExecutionEngineTestCase{ + schema: schemaNested, + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + users(first: 10) { + posts(first: 5) { + comments(first: 3) { text } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"users":[ + {"posts":[ + {"comments":[{"text":"a"},{"text":"b"}]}, + {"comments":[{"text":"c"},{"text":"d"}]} + ]}, + {"posts":[]}, + {"posts":[ + {"comments":[]} + ]} + ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + ChildNodes: childNodes, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "users"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "User", FieldName: "posts"}: { + AssumedSize: 50, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Post", FieldName: "comments"}: { + AssumedSize: 20, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "User": 4, + "Post": 3, + "Comment": 2, + }, + }, + }, + customConfig, + ), + }, + fields: fieldConfig, + expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"a"},{"text":"b"}]},{"comments":[{"text":"c"},{"text":"d"}]}]},{"posts":[]},{"posts":[{"comments":[]}]}]}}`, + expectedEstimatedCost: 640, // 10 * (4 + 5 * (3 + 3 * (2 + 1))) + // Actual cost with mixed empty/non-empty lists: + // Users: 3 items, multiplier 3.0 + // Posts: 3 items, 3 parents => multiplier 1.0 (avg) + // Comments: 4 items, 3 parents => multiplier 1.33 (avg) + // + // Calculation: + // Comments: RoundToEven((2 + 1) * 1.33) ~= 4 + // Posts: RoundToEven((3 + 4) * 1.00) = 7 + // Users: RoundToEven((4 + 7) * 3.00) = 33 + // + // Empty lists are included in the averaging: + expectedActualCost: 33, + }, + computeCosts(), + )) + + t.Run("deeply nested lists with fractional multipliers - compounding rounding", runWithoutError( + ExecutionEngineTestCase{ + schema: func() *graphql.Schema { + deepSchema := ` + type Query { + level1(first: Int): [Level1!] + } + type Level1 @key(fields: "id") { + id: ID! + level2(first: Int): [Level2!] + } + type Level2 @key(fields: "id") { + id: ID! + level3(first: Int): [Level3!] + } + type Level3 @key(fields: "id") { + id: ID! + level4(first: Int): [Level4!] + } + type Level4 @key(fields: "id") { + id: ID! + level5(first: Int): [Level5!] + } + type Level5 @key(fields: "id") { + id: ID! + value: String! + } + ` + s, err := graphql.NewSchemaFromString(deepSchema) + require.NoError(t, err) + return s + }(), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ + level1(first: 10) { + level2(first: 10) { + level3(first: 10) { + level4(first: 10) { + level5(first: 10) { + value + } + } + } + } + } + }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", + expectedPath: "/", + expectedBody: "", + sendResponseBody: `{"data":{"level1":[ + {"level2":[ + {"level3":[ + {"level4":[ + {"level5":[{"value":"a"}]}, + {"level5":[{"value":"b"},{"value":"c"}]} + ]}, + {"level4":[ + {"level5":[{"value":"d"}]} + ]} + ]}, + {"level3":[ + {"level4":[ + {"level5":[{"value":"e"}]} + ]} + ]} + ]}, + {"level2":[ + {"level3":[ + {"level4":[ + {"level5":[{"value":"f"},{"value":"g"}]}, + {"level5":[{"value":"h"}]} + ]}, + {"level4":[ + {"level5":[{"value":"i"}]} + ]} + ]} + ]}, + {"level2":[ + {"level3":[ + {"level4":[ + {"level5":[{"value":"j"}]}, + {"level5":[{"value":"k"}]} + ]}, + {"level4":[ + {"level5":[{"value":"l"}]}, + {"level5":[{"value":"m"}]} + ]} + ]} + ]} + ]}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{ + RootNodes: []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"level1"}}, + {TypeName: "Level1", FieldNames: []string{"id", "level2"}}, + {TypeName: "Level2", FieldNames: []string{"id", "level3"}}, + {TypeName: "Level3", FieldNames: []string{"id", "level4"}}, + {TypeName: "Level4", FieldNames: []string{"id", "level5"}}, + {TypeName: "Level5", FieldNames: []string{"id", "value"}}, + }, + ChildNodes: []plan.TypeField{}, + CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ + {TypeName: "Level5", FieldName: "value"}: {HasWeight: true, Weight: 1}, + }, + ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ + {TypeName: "Query", FieldName: "level1"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Level1", FieldName: "level2"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Level2", FieldName: "level3"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Level3", FieldName: "level4"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + {TypeName: "Level4", FieldName: "level5"}: { + AssumedSize: 100, + SlicingArguments: []string{"first"}, + }, + }, + Types: map[string]int{ + "Level1": 1, + "Level2": 1, + "Level3": 1, + "Level4": 1, + "Level5": 1, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{ + URL: "https://example.com/", + Method: "GET", + }, + SchemaConfiguration: mustSchemaConfig(t, nil, ` + type Query { + level1(first: Int): [Level1!] + } + type Level1 @key(fields: "id") { + id: ID! + level2(first: Int): [Level2!] + } + type Level2 @key(fields: "id") { + id: ID! + level3(first: Int): [Level3!] + } + type Level3 @key(fields: "id") { + id: ID! + level4(first: Int): [Level4!] + } + type Level4 @key(fields: "id") { + id: ID! + level5(first: Int): [Level5!] + } + type Level5 @key(fields: "id") { + id: ID! + value: String! + } + `), + }), + ), + }, + fields: []plan.FieldConfiguration{ + { + TypeName: "Query", FieldName: "level1", Path: []string{"level1"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Level1", FieldName: "level2", Path: []string{"level2"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Level2", FieldName: "level3", Path: []string{"level3"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Level3", FieldName: "level4", Path: []string{"level4"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + { + TypeName: "Level4", FieldName: "level5", Path: []string{"level5"}, + Arguments: []plan.ArgumentConfiguration{ + {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, + }, + }, + }, + expectedResponse: `{"data":{"level1":[{"level2":[{"level3":[{"level4":[{"level5":[{"value":"a"}]},{"level5":[{"value":"b"},{"value":"c"}]}]},{"level4":[{"level5":[{"value":"d"}]}]}]},{"level3":[{"level4":[{"level5":[{"value":"e"}]}]}]}]},{"level2":[{"level3":[{"level4":[{"level5":[{"value":"f"},{"value":"g"}]},{"level5":[{"value":"h"}]}]},{"level4":[{"level5":[{"value":"i"}]}]}]}]},{"level2":[{"level3":[{"level4":[{"level5":[{"value":"j"}]},{"level5":[{"value":"k"}]}]},{"level4":[{"level5":[{"value":"l"}]},{"level5":[{"value":"m"}]}]}]}]}]}}`, + expectedEstimatedCost: 211110, + // Actual cost with fractional multipliers: + // Level5: 13 items, 11 parents => multiplier 1.18 (13/11 = 1.181818...) + // Level4: 11 items, 7 parents => multiplier 1.57 (11/7 = 1.571428...) + // Level3: 7 items, 4 parents => multiplier 1.75 (7/4 = 1.75) + // Level2: 4 items, 3 parents => multiplier 1.33 (4/3 = 1.333...) + // Level1: 3 items, 1 parent => multiplier 3.0 + // + // Ideal calculation without rounding: + // cost = 3 * (1 + 1.33 * (1 + 1.75 * (1 + 1.57 * (1 + 1.18 * (1 + 1))))) + // = 50.806584 ~= 51 + // + // Current implementation: + // Level5: RoundToEven((1 + 1) * 1.18) = 2 + // Level4: RoundToEven((1 + 2) * 1.57) = 5 + // Level3: RoundToEven((1 + 5) * 1.75) = 10 (rounds to even) + // Level2: RoundToEven((1 + 10) * 1.33) = 15 + // Level1: RoundToEven((1 + 15) * 3.00) = 48 + // + // The compounding rounding error: 48 vs 51 (6% underestimate) + expectedActualCost: 48, + }, + computeCosts(), + )) + }) +} diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index 9c3370efe0..0f7c48ac00 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -17,7 +17,6 @@ import ( "github.com/sebdah/goldie/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/grpc" "github.com/wundergraph/graphql-go-tools/execution/federationtesting" "github.com/wundergraph/graphql-go-tools/execution/graphql" @@ -61,13 +60,104 @@ func mustFactory(t testing.TB, httpClient *http.Client) plan.PlannerFactory[grap return factory } -func mustFactoryGRPC(t testing.TB, grpcClient grpc.ClientConnInterface) plan.PlannerFactory[graphql_datasource.Configuration] { - t.Helper() +func runExecutionTest(testCase ExecutionEngineTestCase, withError bool, expectedErrorMessage string, options ...executionTestOptions) func(t *testing.T) { + return func(t *testing.T) { + t.Helper() - factory, err := graphql_datasource.NewFactoryGRPC(context.Background(), grpcClient) - require.NoError(t, err) + if testCase.skipReason != "" { + t.Skip(testCase.skipReason) + } - return factory + engineConf := NewConfiguration(testCase.schema) + engineConf.SetDataSources(testCase.dataSources) + engineConf.SetFieldConfigurations(testCase.fields) + engineConf.SetCustomResolveMap(testCase.customResolveMap) + + engineConf.plannerConfig.Debug = plan.DebugConfiguration{ + // PrintOperationTransformations: true, + // PrintPlanningPaths: true, + // PrintNodeSuggestions: true, + // PrintQueryPlans: true, + // ConfigurationVisitor: true, + // PlanningVisitor: true, + // DatasourceVisitor: true, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var opts _executionTestOptions + for _, option := range options { + option(&opts) + } + engineConf.plannerConfig.BuildFetchReasons = opts.propagateFetchReasons + engineConf.plannerConfig.ValidateRequiredExternalFields = opts.validateRequiredExternalFields + engineConf.plannerConfig.ComputeCosts = opts.computeCosts + engineConf.plannerConfig.StaticCostDefaultListSize = 10 + engineConf.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.relaxFieldSelectionMergingNullability + resolveOpts := resolve.ResolverOptions{ + MaxConcurrency: 1024, + ResolvableOptions: opts.resolvableOptions, + ApolloRouterCompatibilitySubrequestHTTPError: opts.apolloRouterCompatibilitySubrequestHTTPError, + PropagateFetchReasons: opts.propagateFetchReasons, + ValidateRequiredExternalFields: opts.validateRequiredExternalFields, + } + engine, err := NewExecutionEngine(ctx, abstractlogger.Noop{}, engineConf, resolveOpts) + require.NoError(t, err) + + operation := testCase.operation(t) + resultWriter := graphql.NewEngineResultWriter() + execCtx, execCtxCancel := context.WithCancel(context.Background()) + defer execCtxCancel() + err = engine.Execute(execCtx, &operation, &resultWriter, testCase.engineOptions...) + actualResponse := resultWriter.String() + + if testCase.indentJSON { + dst := new(bytes.Buffer) + require.NoError(t, json.Indent(dst, []byte(actualResponse), "", " ")) + actualResponse = dst.String() + } + + if testCase.expectedFixture != "" { + g := goldie.New(t, goldie.WithFixtureDir("testdata"), goldie.WithNameSuffix(".json")) + g.Assert(t, testCase.expectedFixture, []byte(actualResponse)) + return + } + + if withError { + require.Error(t, err) + if expectedErrorMessage != "" { + assert.Equal(t, expectedErrorMessage, err.Error()) + } + } else { + require.NoError(t, err) + } + + if testCase.expectedJSONResponse != "" { + assert.JSONEq(t, testCase.expectedJSONResponse, actualResponse) + } + + if testCase.expectedResponse != "" { + assert.Equal(t, testCase.expectedResponse, actualResponse) + } + + if testCase.expectedEstimatedCost != 0 { + gotCost := operation.EstimatedCost() + require.Equal(t, testCase.expectedEstimatedCost, gotCost) + } + + if testCase.expectedActualCost != 0 { + gotActualCost := operation.ActualCost() + require.Equal(t, testCase.expectedActualCost, gotActualCost) + } + } +} + +func runWithAndCompareError(testCase ExecutionEngineTestCase, expectedErrorMessage string, options ...executionTestOptions) func(t *testing.T) { + return runExecutionTest(testCase, true, expectedErrorMessage, options...) +} + +func runWithoutError(testCase ExecutionEngineTestCase, options ...executionTestOptions) func(t *testing.T) { + return runExecutionTest(testCase, false, "", options...) } func mustGraphqlDataSourceConfiguration(t *testing.T, id string, factory plan.PlannerFactory[graphql_datasource.Configuration], metadata *plan.DataSourceMetadata, customConfig graphql_datasource.Configuration) plan.DataSourceConfiguration[graphql_datasource.Configuration] { @@ -262,113 +352,6 @@ func relaxFieldSelectionMergingNullability() executionTestOptions { } func TestExecutionEngine_Execute(t *testing.T) { - run := func(testCase ExecutionEngineTestCase, withError bool, expectedErrorMessage string, options ...executionTestOptions) func(t *testing.T) { - t.Helper() - - return func(t *testing.T) { - t.Helper() - - if testCase.skipReason != "" { - t.Skip(testCase.skipReason) - } - - engineConf := NewConfiguration(testCase.schema) - engineConf.SetDataSources(testCase.dataSources) - engineConf.SetFieldConfigurations(testCase.fields) - engineConf.SetCustomResolveMap(testCase.customResolveMap) - - engineConf.plannerConfig.Debug = plan.DebugConfiguration{ - // PrintOperationTransformations: true, - // PrintPlanningPaths: true, - // PrintNodeSuggestions: true, - // PrintQueryPlans: true, - // ConfigurationVisitor: true, - // PlanningVisitor: true, - // DatasourceVisitor: true, - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - var opts _executionTestOptions - for _, option := range options { - option(&opts) - } - engineConf.plannerConfig.BuildFetchReasons = opts.propagateFetchReasons - engineConf.plannerConfig.ValidateRequiredExternalFields = opts.validateRequiredExternalFields - engineConf.plannerConfig.ComputeCosts = opts.computeCosts - engineConf.plannerConfig.StaticCostDefaultListSize = 10 - engineConf.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.relaxFieldSelectionMergingNullability - resolveOpts := resolve.ResolverOptions{ - MaxConcurrency: 1024, - ResolvableOptions: opts.resolvableOptions, - ApolloRouterCompatibilitySubrequestHTTPError: opts.apolloRouterCompatibilitySubrequestHTTPError, - PropagateFetchReasons: opts.propagateFetchReasons, - ValidateRequiredExternalFields: opts.validateRequiredExternalFields, - } - engine, err := NewExecutionEngine(ctx, abstractlogger.Noop{}, engineConf, resolveOpts) - require.NoError(t, err) - - operation := testCase.operation(t) - resultWriter := graphql.NewEngineResultWriter() - execCtx, execCtxCancel := context.WithCancel(context.Background()) - defer execCtxCancel() - err = engine.Execute(execCtx, &operation, &resultWriter, testCase.engineOptions...) - actualResponse := resultWriter.String() - - if testCase.indentJSON { - dst := new(bytes.Buffer) - require.NoError(t, json.Indent(dst, []byte(actualResponse), "", " ")) - actualResponse = dst.String() - } - - if testCase.expectedFixture != "" { - g := goldie.New(t, goldie.WithFixtureDir("testdata"), goldie.WithNameSuffix(".json")) - g.Assert(t, testCase.expectedFixture, []byte(actualResponse)) - return - } - - if withError { - require.Error(t, err) - if expectedErrorMessage != "" { - assert.Equal(t, expectedErrorMessage, err.Error()) - } - } else { - require.NoError(t, err) - } - - if testCase.expectedJSONResponse != "" { - assert.JSONEq(t, testCase.expectedJSONResponse, actualResponse) - } - - if testCase.expectedResponse != "" { - assert.Equal(t, testCase.expectedResponse, actualResponse) - } - - if testCase.expectedEstimatedCost != 0 { - gotCost := operation.EstimatedCost() - require.Equal(t, testCase.expectedEstimatedCost, gotCost) - } - - if testCase.expectedActualCost != 0 { - gotActualCost := operation.ActualCost() - require.Equal(t, testCase.expectedActualCost, gotActualCost) - } - - } - } - - runWithAndCompareError := func(testCase ExecutionEngineTestCase, expectedErrorMessage string, options ...executionTestOptions) func(t *testing.T) { - t.Helper() - - return run(testCase, true, expectedErrorMessage, options...) - } - - runWithoutError := func(testCase ExecutionEngineTestCase, options ...executionTestOptions) func(t *testing.T) { - t.Helper() - - return run(testCase, false, "", options...) - } - t.Run("apollo router compatibility subrequest HTTP error enabled", runWithoutError( ExecutionEngineTestCase{ schema: graphql.StarwarsSchema(t), @@ -2346,7 +2329,7 @@ func TestExecutionEngine_Execute(t *testing.T) { schema, err := graphql.NewSchemaFromString(enumSDL) require.NoError(t, err) - t.Run("invalid non-nullable enum input", run( + t.Run("invalid non-nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -2409,7 +2392,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INVALID"; Value "INVALID" does not exist in "Enum" enum.`, )) - t.Run("nested invalid non-nullable enum input", run( + t.Run("nested invalid non-nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -2480,7 +2463,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INVALID"; Value "INVALID" does not exist in "Enum" enum.`, )) - t.Run("invalid nullable enum input", run( + t.Run("invalid nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -2543,7 +2526,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INVALID"; Value "INVALID" does not exist in "Enum" enum.`, )) - t.Run("nested invalid nullable enum input", run( + t.Run("nested invalid nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -3214,7 +3197,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, )) - t.Run("inaccessible non-nullable enum input", run( + t.Run("inaccessible non-nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -3277,7 +3260,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INACCESSIBLE"; Value "INACCESSIBLE" does not exist in "Enum" enum.`, )) - t.Run("nested inaccessible non-nullable enum input", run( + t.Run("nested inaccessible non-nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -3348,7 +3331,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INACCESSIBLE"; Value "INACCESSIBLE" does not exist in "Enum" enum.`, )) - t.Run("inaccessible nullable enum input", run( + t.Run("inaccessible nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -3411,7 +3394,7 @@ func TestExecutionEngine_Execute(t *testing.T) { }, true, `Variable "$enum" got invalid value "INACCESSIBLE"; Value "INACCESSIBLE" does not exist in "Enum" enum.`, )) - t.Run("nested inaccessible nullable enum input", run( + t.Run("nested inaccessible nullable enum input", runExecutionTest( ExecutionEngineTestCase{ schema: schema, operation: func(t *testing.T) graphql.Request { @@ -5646,1948 +5629,6 @@ func TestExecutionEngine_Execute(t *testing.T) { }) }) - t.Run("costs computation", func(t *testing.T) { - t.Run("common on star wars scheme", func(t *testing.T) { - rootNodes := []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"hero", "droid"}}, - {TypeName: "Human", FieldNames: []string{"name", "height", "friends"}}, - {TypeName: "Droid", FieldNames: []string{"name", "primaryFunction", "friends"}}, - } - childNodes := []plan.TypeField{ - {TypeName: "Character", FieldNames: []string{"name", "friends"}}, - } - customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, - SchemaConfiguration: mustSchemaConfig( - t, - nil, - string(graphql.StarwarsSchema(t).RawSchema()), - ), - }) - - t.Run("droid with weighted plain fields", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - droid(id: "R2D2") { - name - primaryFunction - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, - }, - }}, - customConfig, - ), - }, - fields: []plan.FieldConfiguration{ - { - TypeName: "Query", FieldName: "droid", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "id", - SourceType: plan.FieldArgumentSource, - RenderConfig: plan.RenderArgumentAsGraphQLValue, - }, - }, - }, - }, - expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - expectedEstimatedCost: 18, // Query.droid (1) + droid.name (17) - }, - computeCosts(), - )) - - t.Run("droid with weighted plain fields and an argument", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - droid(id: "R2D2") { - name - primaryFunction - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Query", FieldName: "droid"}: { - ArgumentWeights: map[string]int{"id": 3}, - HasWeight: false, - }, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, - }, - }}, - customConfig, - ), - }, - fields: []plan.FieldConfiguration{ - { - TypeName: "Query", FieldName: "droid", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "id", - SourceType: plan.FieldArgumentSource, - RenderConfig: plan.RenderArgumentAsGraphQLValue, - }, - }, - }, - }, - expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - expectedEstimatedCost: 21, // Query.droid (1) + Query.droid.id (3) + droid.name (17) - }, - computeCosts(), - )) - - t.Run("negative weights - cost is never negative", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - droid(id: "R2D2") { - name - primaryFunction - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Query", FieldName: "droid"}: { - HasWeight: true, - Weight: -10, // Negative field weight - ArgumentWeights: map[string]int{"id": -5}, // Negative argument weight - }, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: -3}, - {TypeName: "Droid", FieldName: "primaryFunction"}: {HasWeight: true, Weight: -2}, - }, - Types: map[string]int{ - "Droid": -1, // Negative type weight - }, - }}, - customConfig, - ), - }, - fields: []plan.FieldConfiguration{ - { - TypeName: "Query", FieldName: "droid", - Arguments: []plan.ArgumentConfiguration{ - { - Name: "id", - SourceType: plan.FieldArgumentSource, - RenderConfig: plan.RenderArgumentAsGraphQLValue, - }, - }, - }, - }, - expectedResponse: `{"data":{"droid":{"name":"R2D2","primaryFunction":"no"}}}`, - // All weights are negative. - // But cost should be floored to 0 (never negative) - expectedEstimatedCost: 0, - }, - computeCosts(), - )) - - t.Run("hero field has weight (returns interface) and with concrete fragment", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - name - ... on Human { height } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke Skywalker","height":"12"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{RootNodes: rootNodes, ChildNodes: childNodes, CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 3}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 7}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, - }, - Types: map[string]int{ - "Human": 13, - }, - }}, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"name":"Luke Skywalker","height":"12"}}}`, - expectedEstimatedCost: 22, // Query.hero (2) + Human.height (3) + Droid.name (17=max(7, 17)) - }, - computeCosts(), - )) - - t.Run("hero field has no weight (returns interface) and with concrete fragment", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { name } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke Skywalker"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{RootNodes: rootNodes, ChildNodes: childNodes, CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 7}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 17}, - }, - Types: map[string]int{ - "Human": 13, - "Droid": 11, - }, - }}, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"name":"Luke Skywalker"}}}`, - expectedEstimatedCost: 30, // Query.Human (13) + Droid.name (17=max(7, 17)) - }, - computeCosts(), - )) - - t.Run("query hero without assumedSize on friends", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name primaryFunction } - ...on Human { name height } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ - {"__typename":"Human","name":"Luke Skywalker","height":"12"}, - {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} - ]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, - }, - Types: map[string]int{ - "Human": 7, - "Droid": 5, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - expectedEstimatedCost: 127, // Query.hero(max(7,5))+10*(Human(max(7,5))+Human.name(2)+Human.height(1)+Droid.name(2)) - }, - computeCosts(), - )) - - t.Run("query hero with assumedSize on friends", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name primaryFunction } - ...on Human { name height } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ - {"__typename":"Human","name":"Luke Skywalker","height":"12"}, - {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} - ]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 5}, - {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 20}, - }, - Types: map[string]int{ - "Human": 7, - "Droid": 5, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - expectedEstimatedCost: 247, // Query.hero(max(7,5))+ 20 * (7+2+2+1) - // We pick maximum on every path independently. This is to reveal the upper boundary. - // Query.hero: picked maximum weight (Human=7) out of two types (Human, Droid) - // Query.hero.friends: the max possible weight (7) is for implementing class Human - // of the returned type of Character; the multiplier picked for the Droid since - // it is the maximum possible value - we considered the enclosing type that contains it. - }, - computeCosts(), - )) - - t.Run("query hero with assumedSize on friends and weight defined", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name primaryFunction } - ...on Human { name height } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ - {"__typename":"Human","name":"Luke Skywalker","height":"12"}, - {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} - ]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "friends"}: {HasWeight: true, Weight: 3}, - {TypeName: "Droid", FieldName: "friends"}: {HasWeight: true, Weight: 4}, - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 5}, - {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 20}, - }, - Types: map[string]int{ - "Human": 7, - "Droid": 5, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - expectedEstimatedCost: 187, // Query.hero(max(7,5))+ 20 * (4+2+2+1) - }, - computeCosts(), - )) - - t.Run("query hero with empty cost structures", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name primaryFunction } - ...on Human { name height } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ - {"__typename":"Human","name":"Luke Skywalker","height":"12"}, - {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} - ]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{}, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - expectedEstimatedCost: 11, // Query.hero(max(1,1))+ 10 * 1 - }, - computeCosts(), - )) - - // Actual cost tests - verifies that actual cost uses real list sizes from response - // rather than estimated/assumed sizes - - t.Run("actual cost with list field - 2 items instead of default 10", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name primaryFunction } - ...on Human { name height } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - // Response has 2 friends (not 10 as estimated) - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[ - {"__typename":"Human","name":"Luke Skywalker","height":"12"}, - {"__typename":"Droid","name":"R2DO","primaryFunction":"joke"} - ]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 1}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, - }, - Types: map[string]int{ - "Human": 7, - "Droid": 5, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[{"name":"Luke Skywalker","height":"12"},{"name":"R2DO","primaryFunction":"joke"}]}}}`, - // Estimated with default list size 10: hero(7) + 10 * (7 + 2 + 2 + 1) = 127 - expectedEstimatedCost: 127, - // Actual uses real list size 2: hero(7) + 2 * (7 + 2 + 2 + 1) = 31 - expectedActualCost: 31, - }, - computeCosts(), - )) - - t.Run("actual cost with empty list", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - hero { - friends { - ...on Droid { name } - ...on Human { name } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - // Response has empty friends array - sendResponseBody: `{"data":{"hero":{"__typename":"Human","friends":[]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 2}, - }, - Types: map[string]int{ - "Human": 7, - "Droid": 5, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"friends":[]}}}`, - // Estimated with default list size 10: hero(7) + 10 * (7 + 2 + 2) = 117 - expectedEstimatedCost: 117, - // Actual with empty list: hero(7) + 1 * (7 + 2 + 2) = 18 - // We consider empty lists as lists containing one item to account for the - // resolver work. - expectedActualCost: 18, - }, - computeCosts(), - )) - - t.Run("named fragment on interface", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: ` - fragment CharacterFields on Character { - name - friends { name } - } - { hero { ...CharacterFields } } - `, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke","friends":[{"name":"Leia"}]}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 3}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 5}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Human", FieldName: "friends"}: {AssumedSize: 4}, - {TypeName: "Droid", FieldName: "friends"}: {AssumedSize: 6}, - }, - Types: map[string]int{ - "Human": 2, - "Droid": 3, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"name":"Luke","friends":[{"name":"Leia"}]}}}`, - // Cost calculation: - // Query.hero: 2 - // Character.name: max(Human.name=3, Droid.name=5) = 5 - // friends listSize: max(4, 6) = 6 - // Character type: max(Human=2, Droid=3) = 3 - // name: max(Human.name=3, Droid.name=5) = 5 - // Total: 2 + 5 + 6 * (3 + 5) - expectedEstimatedCost: 55, - }, - computeCosts(), - )) - - t.Run("named fragment with concrete type", runWithoutError( - ExecutionEngineTestCase{ - schema: graphql.StarwarsSchema(t), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: ` - fragment HumanFields on Human { - name - height - } - { hero { ...HumanFields } } - `, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"hero":{"__typename":"Human","name":"Luke","height":"1.72"}}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Query", FieldName: "hero"}: {HasWeight: true, Weight: 2}, - {TypeName: "Human", FieldName: "name"}: {HasWeight: true, Weight: 3}, - {TypeName: "Human", FieldName: "height"}: {HasWeight: true, Weight: 7}, - {TypeName: "Droid", FieldName: "name"}: {HasWeight: true, Weight: 5}, - }, - Types: map[string]int{ - "Human": 1, - "Droid": 1, - }, - }, - }, - customConfig, - ), - }, - expectedResponse: `{"data":{"hero":{"name":"Luke","height":"1.72"}}}`, - // Total: 2 + 3 + 7 - expectedEstimatedCost: 12, - }, - computeCosts(), - )) - - }) - - t.Run("union types", func(t *testing.T) { - unionSchema := ` - type Query { - search(term: String!): [SearchResult!] - } - union SearchResult = User | Post | Comment - type User @key(fields: "id") { - id: ID! - name: String! - email: String! - } - type Post @key(fields: "id") { - id: ID! - title: String! - body: String! - } - type Comment @key(fields: "id") { - id: ID! - text: String! - } - ` - schema, err := graphql.NewSchemaFromString(unionSchema) - require.NoError(t, err) - - rootNodes := []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"search"}}, - {TypeName: "User", FieldNames: []string{"id", "name", "email"}}, - {TypeName: "Post", FieldNames: []string{"id", "title", "body"}}, - {TypeName: "Comment", FieldNames: []string{"id", "text"}}, - } - childNodes := []plan.TypeField{} - customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, - SchemaConfiguration: mustSchemaConfig(t, nil, unionSchema), - }) - fieldConfig := []plan.FieldConfiguration{ - { - TypeName: "Query", - FieldName: "search", - Path: []string{"search"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "term", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - } - - t.Run("union with all member types", runWithoutError( - ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - search(term: "test") { - ... on User { name email } - ... on Post { title body } - ... on Comment { text } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"search":[{"__typename":"User","name":"John","email":"john@test.com"}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "User", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "User", FieldName: "email"}: {HasWeight: true, Weight: 3}, - {TypeName: "Post", FieldName: "title"}: {HasWeight: true, Weight: 4}, - {TypeName: "Post", FieldName: "body"}: {HasWeight: true, Weight: 5}, - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "search"}: {AssumedSize: 5}, - }, - Types: map[string]int{ - "User": 2, - "Post": 3, - "Comment": 1, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"search":[{"name":"John","email":"john@test.com"}]}}`, - // search listSize: 10 - // For each SearchResult, use max across all union members: - // Type weight: max(User=2, Post=3, Comment=1) = 3 - // Fields: all fields from all fragments are counted - // (2 + 3) + (4 + 5) + (1) = 15 - // TODO: this is not correct, we should pick a maximum sum among types implementing union. - // 9 should be used instead of 15 - // Total: 5 * (3 + 15) - expectedEstimatedCost: 90, - }, - computeCosts(), - )) - - t.Run("union with weighted search field", runWithoutError( - ExecutionEngineTestCase{ - schema: schema, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - search(term: "test") { - ... on User { name } - ... on Post { title } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"search":[{"__typename":"User","name":"John"}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "User", FieldName: "name"}: {HasWeight: true, Weight: 2}, - {TypeName: "Post", FieldName: "title"}: {HasWeight: true, Weight: 5}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "search"}: {AssumedSize: 3}, - }, - Types: map[string]int{ - "User": 6, - "Post": 10, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"search":[{"name":"John"}]}}`, - // Query.search: max(User=10, Post=6) - // search listSize: 3 - // Union members: - // All fields from all fragments: User.name(2) + Post.title(5) - // Total: 3 * (10+2+5) - // TODO: we might correct this by counting only members of one implementing types - // of a union when fragments are used. - expectedEstimatedCost: 51, - }, - computeCosts(), - )) - }) - - t.Run("listSize", func(t *testing.T) { - listSchema := ` - type Query { - items(first: Int, last: Int): [Item!] - } - type Item @key(fields: "id") { - id: ID - } - ` - schemaSlicing, err := graphql.NewSchemaFromString(listSchema) - require.NoError(t, err) - rootNodes := []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"items"}}, - {TypeName: "Item", FieldNames: []string{"id"}}, - } - childNodes := []plan.TypeField{} - customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, - SchemaConfiguration: mustSchemaConfig(t, nil, listSchema), - }) - fieldConfig := []plan.FieldConfiguration{ - { - TypeName: "Query", - FieldName: "items", - Path: []string{"items"}, - Arguments: []plan.ArgumentConfiguration{ - { - Name: "first", - SourceType: plan.FieldArgumentSource, - RenderConfig: plan.RenderArgumentAsGraphQLValue, - }, - { - Name: "last", - SourceType: plan.FieldArgumentSource, - RenderConfig: plan.RenderArgumentAsGraphQLValue, - }, - }, - }, - } - t.Run("multiple slicing arguments as literals", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaSlicing, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `query MultipleSlicingArguments { - items(first: 5, last: 12) { id } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"items":[ {"id":"2"}, {"id":"3"} ]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "items"}: { - AssumedSize: 8, - SlicingArguments: []string{"first", "last"}, - }, - }, - Types: map[string]int{ - "Item": 3, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"items":[{"id":"2"},{"id":"3"}]}}`, - expectedEstimatedCost: 48, // slicingArgument(12) * (Item(3)+Item.id(1)) - }, - computeCosts(), - )) - t.Run("slicing argument as a variable", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaSlicing, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `query SlicingWithVariable($limit: Int!) { - items(first: $limit) { id } - }`, - Variables: []byte(`{"limit": 25}`), - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"items":[ {"id":"2"}, {"id":"3"} ]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "items"}: { - AssumedSize: 8, - SlicingArguments: []string{"first", "last"}, - }, - }, - Types: map[string]int{ - "Item": 3, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"items":[{"id":"2"},{"id":"3"}]}}`, - expectedEstimatedCost: 100, // slicingArgument($limit=25) * (Item(3)+Item.id(1)) - }, - computeCosts(), - )) - t.Run("slicing argument not provided falls back to assumedSize", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaSlicing, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `query NoSlicingArg { - items { id } - }`, - // No slicing arguments provided - should fall back to assumedSize - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"items":[{"id":"1"},{"id":"2"}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "items"}: { - AssumedSize: 15, - SlicingArguments: []string{"first", "last"}, - }, - }, - Types: map[string]int{ - "Item": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"items":[{"id":"1"},{"id":"2"}]}}`, - expectedEstimatedCost: 45, // Total: 15 * (2 + 1) - }, - computeCosts(), - )) - t.Run("zero slicing argument falls back to assumedSize", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaSlicing, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `query ZeroSlicing { - items(first: 0) { id } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"items":[]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "items"}: { - AssumedSize: 20, - SlicingArguments: []string{"first", "last"}, - }, - }, - Types: map[string]int{ - "Item": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"items":[]}}`, - expectedEstimatedCost: 60, // 20 * (2 + 1) - }, - computeCosts(), - )) - t.Run("negative slicing argument falls back to assumedSize", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaSlicing, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `query NegativeSlicing { - items(first: -5) { id } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", expectedPath: "/", expectedBody: "", - sendResponseBody: `{"data":{"items":[]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Item", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "items"}: { - AssumedSize: 25, - SlicingArguments: []string{"first", "last"}, - }, - }, - Types: map[string]int{ - "Item": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"items":[]}}`, - expectedEstimatedCost: 75, // 25 * (2 + 1) - }, - computeCosts(), - )) - - }) - - t.Run("nested lists with compounding multipliers", func(t *testing.T) { - nestedSchema := ` - type Query { - users(first: Int): [User!] - } - type User @key(fields: "id") { - id: ID! - posts(first: Int): [Post!] - } - type Post @key(fields: "id") { - id: ID! - comments(first: Int): [Comment!] - } - type Comment @key(fields: "id") { - id: ID! - text: String! - } - ` - schemaNested, err := graphql.NewSchemaFromString(nestedSchema) - require.NoError(t, err) - - rootNodes := []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"users"}}, - {TypeName: "User", FieldNames: []string{"id", "posts"}}, - {TypeName: "Post", FieldNames: []string{"id", "comments"}}, - {TypeName: "Comment", FieldNames: []string{"id", "text"}}, - } - childNodes := []plan.TypeField{} - customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, - SchemaConfiguration: mustSchemaConfig(t, nil, nestedSchema), - }) - fieldConfig := []plan.FieldConfiguration{ - { - TypeName: "Query", FieldName: "users", Path: []string{"users"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "User", FieldName: "posts", Path: []string{"posts"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "Post", FieldName: "comments", Path: []string{"comments"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - } - - t.Run("nested lists with slicing arguments", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 10) { - posts(first: 5) { - comments(first: 3) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, - // Cost calculation: - // users(first:10): multiplier 10 - // User type weight: 4 - // posts(first:5): multiplier 5 - // Post type weight: 3 - // comments(first:3): multiplier 3 - // Comment type weight: 2 - // text weight: 1 - // Total: 10 * (4 + 5 * (3 + 3 * (2 + 1))) - expectedEstimatedCost: 640, - }, - computeCosts(), - )) - - t.Run("nested lists fallback to assumedSize when slicing arg not provided", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 2) { - posts { - comments(first: 4) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hi"}]}]}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, // no slicing arg, should use this - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hi"}]}]}]}}`, - // Cost calculation: - // users(first:2): multiplier 2 - // User type weight: 4 - // posts (no arg): assumedSize 50 - // Post type weight: 3 - // comments(first:4): multiplier 4 - // Comment type weight: 2 - // text weight: 1 - // Total: 2 * (4 + 50 * (3 + 4 * (2 + 1))) - expectedEstimatedCost: 1508, - }, - computeCosts(), - )) - - t.Run("actual cost for nested lists - 1 item at each level", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 10) { - posts(first: 5) { - comments(first: 3) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - // Response has 1 user with 1 post with 1 comment - sendResponseBody: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"hello"}]}]}]}}`, - // Estimated cost with slicing arguments (10, 5, 3): - // Total: 10 * (4 + 5 * (3 + 3 * (2 + 1))) = 640 - expectedEstimatedCost: 640, - // Actual cost with 1 item at each level: - // Total: 1 * (4 + 1 * (3 + 1 * (2 + 1))) = 10 - expectedActualCost: 10, - }, - computeCosts(), - )) - - t.Run("actual cost for nested lists - varying sizes", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 10) { - posts(first: 5) { - comments(first: 3) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - // Response has 2 users, each with 2 posts, each with 3 comments - sendResponseBody: `{"data":{"users":[ - {"posts":[ - {"comments":[{"text":"a"},{"text":"b"},{"text":"c"}]}, - {"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]}, - {"posts":[ - {"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]}, - {"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"a"},{"text":"b"},{"text":"c"}]},{"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]},{"posts":[{"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]},{"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, - expectedEstimatedCost: 640, - // Actual cost: 2 * (4 + 2 * (3 + 3 * (2 + 1))) = 56 - expectedActualCost: 56, - }, - computeCosts(), - )) - - t.Run("actual cost for nested lists - uneven sizes", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 10) { - posts(first: 5) { - comments(first: 2) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - // Response has 2 users, with 1.5 posts each, each with 3 comments - sendResponseBody: `{"data":{"users":[ - {"posts":[ - {"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]}, - {"posts":[ - {"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]}, - {"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"d"},{"text":"e"},{"text":"f"}]}]},{"posts":[{"comments":[{"text":"g"},{"text":"h"},{"text":"i"}]},{"comments":[{"text":"j"},{"text":"k"},{"text":"l"}]}]}]}}`, - // Estimated : 10 * (4 + 5 * (3 + 2 * (2 + 1))) = 490 - expectedEstimatedCost: 490, - // Actual cost: 2 * (4 + 1.5 * (3 + 3 * (2 + 1))) = 44 - expectedActualCost: 44, - }, - computeCosts(), - )) - - t.Run("actual cost for root-level list - no parent", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ users(first: 10) { id } }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - // Response has 3 users at the root level - sendResponseBody: `{"data":{"users":[ - {"id":"1"}, - {"id":"2"}, - {"id":"3"}]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "User", FieldName: "id"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"id":"1"},{"id":"2"},{"id":"3"}]}}`, - // Estimated: 10 * (4 + 1) = 50 - expectedEstimatedCost: 50, - // Actual cost: 3 users at root - // 3 * (4 + 1) = 15 - expectedActualCost: 15, - }, - computeCosts(), - )) - - t.Run("mixed empty and non-empty lists - averaging behavior", runWithoutError( - ExecutionEngineTestCase{ - schema: schemaNested, - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - users(first: 10) { - posts(first: 5) { - comments(first: 3) { text } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"users":[ - {"posts":[ - {"comments":[{"text":"a"},{"text":"b"}]}, - {"comments":[{"text":"c"},{"text":"d"}]} - ]}, - {"posts":[]}, - {"posts":[ - {"comments":[]} - ]} - ]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: rootNodes, - ChildNodes: childNodes, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Comment", FieldName: "text"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "users"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "User", FieldName: "posts"}: { - AssumedSize: 50, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Post", FieldName: "comments"}: { - AssumedSize: 20, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "User": 4, - "Post": 3, - "Comment": 2, - }, - }, - }, - customConfig, - ), - }, - fields: fieldConfig, - expectedResponse: `{"data":{"users":[{"posts":[{"comments":[{"text":"a"},{"text":"b"}]},{"comments":[{"text":"c"},{"text":"d"}]}]},{"posts":[]},{"posts":[{"comments":[]}]}]}}`, - expectedEstimatedCost: 640, // 10 * (4 + 5 * (3 + 3 * (2 + 1))) - // Actual cost with mixed empty/non-empty lists: - // Users: 3 items, multiplier 3.0 - // Posts: 3 items, 3 parents => multiplier 1.0 (avg) - // Comments: 4 items, 3 parents => multiplier 1.33 (avg) - // - // Calculation: - // Comments: RoundToEven((2 + 1) * 1.33) ~= 4 - // Posts: RoundToEven((3 + 4) * 1.00) = 7 - // Users: RoundToEven((4 + 7) * 3.00) = 33 - // - // Empty lists are included in the averaging: - expectedActualCost: 33, - }, - computeCosts(), - )) - - t.Run("deeply nested lists with fractional multipliers - compounding rounding", runWithoutError( - ExecutionEngineTestCase{ - schema: func() *graphql.Schema { - deepSchema := ` - type Query { - level1(first: Int): [Level1!] - } - type Level1 @key(fields: "id") { - id: ID! - level2(first: Int): [Level2!] - } - type Level2 @key(fields: "id") { - id: ID! - level3(first: Int): [Level3!] - } - type Level3 @key(fields: "id") { - id: ID! - level4(first: Int): [Level4!] - } - type Level4 @key(fields: "id") { - id: ID! - level5(first: Int): [Level5!] - } - type Level5 @key(fields: "id") { - id: ID! - value: String! - } - ` - s, err := graphql.NewSchemaFromString(deepSchema) - require.NoError(t, err) - return s - }(), - operation: func(t *testing.T) graphql.Request { - return graphql.Request{ - Query: `{ - level1(first: 10) { - level2(first: 10) { - level3(first: 10) { - level4(first: 10) { - level5(first: 10) { - value - } - } - } - } - } - }`, - } - }, - dataSources: []plan.DataSource{ - mustGraphqlDataSourceConfiguration(t, "id", - mustFactory(t, - testNetHttpClient(t, roundTripperTestCase{ - expectedHost: "example.com", - expectedPath: "/", - expectedBody: "", - sendResponseBody: `{"data":{"level1":[ - {"level2":[ - {"level3":[ - {"level4":[ - {"level5":[{"value":"a"}]}, - {"level5":[{"value":"b"},{"value":"c"}]} - ]}, - {"level4":[ - {"level5":[{"value":"d"}]} - ]} - ]}, - {"level3":[ - {"level4":[ - {"level5":[{"value":"e"}]} - ]} - ]} - ]}, - {"level2":[ - {"level3":[ - {"level4":[ - {"level5":[{"value":"f"},{"value":"g"}]}, - {"level5":[{"value":"h"}]} - ]}, - {"level4":[ - {"level5":[{"value":"i"}]} - ]} - ]} - ]}, - {"level2":[ - {"level3":[ - {"level4":[ - {"level5":[{"value":"j"}]}, - {"level5":[{"value":"k"}]} - ]}, - {"level4":[ - {"level5":[{"value":"l"}]}, - {"level5":[{"value":"m"}]} - ]} - ]} - ]} - ]}}`, - sendStatusCode: 200, - }), - ), - &plan.DataSourceMetadata{ - RootNodes: []plan.TypeField{ - {TypeName: "Query", FieldNames: []string{"level1"}}, - {TypeName: "Level1", FieldNames: []string{"id", "level2"}}, - {TypeName: "Level2", FieldNames: []string{"id", "level3"}}, - {TypeName: "Level3", FieldNames: []string{"id", "level4"}}, - {TypeName: "Level4", FieldNames: []string{"id", "level5"}}, - {TypeName: "Level5", FieldNames: []string{"id", "value"}}, - }, - ChildNodes: []plan.TypeField{}, - CostConfig: &plan.DataSourceCostConfig{ - Weights: map[plan.FieldCoordinate]*plan.FieldWeight{ - {TypeName: "Level5", FieldName: "value"}: {HasWeight: true, Weight: 1}, - }, - ListSizes: map[plan.FieldCoordinate]*plan.FieldListSize{ - {TypeName: "Query", FieldName: "level1"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Level1", FieldName: "level2"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Level2", FieldName: "level3"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Level3", FieldName: "level4"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - {TypeName: "Level4", FieldName: "level5"}: { - AssumedSize: 100, - SlicingArguments: []string{"first"}, - }, - }, - Types: map[string]int{ - "Level1": 1, - "Level2": 1, - "Level3": 1, - "Level4": 1, - "Level5": 1, - }, - }, - }, - mustConfiguration(t, graphql_datasource.ConfigurationInput{ - Fetch: &graphql_datasource.FetchConfiguration{ - URL: "https://example.com/", - Method: "GET", - }, - SchemaConfiguration: mustSchemaConfig(t, nil, ` - type Query { - level1(first: Int): [Level1!] - } - type Level1 @key(fields: "id") { - id: ID! - level2(first: Int): [Level2!] - } - type Level2 @key(fields: "id") { - id: ID! - level3(first: Int): [Level3!] - } - type Level3 @key(fields: "id") { - id: ID! - level4(first: Int): [Level4!] - } - type Level4 @key(fields: "id") { - id: ID! - level5(first: Int): [Level5!] - } - type Level5 @key(fields: "id") { - id: ID! - value: String! - } - `), - }), - ), - }, - fields: []plan.FieldConfiguration{ - { - TypeName: "Query", FieldName: "level1", Path: []string{"level1"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "Level1", FieldName: "level2", Path: []string{"level2"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "Level2", FieldName: "level3", Path: []string{"level3"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "Level3", FieldName: "level4", Path: []string{"level4"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - { - TypeName: "Level4", FieldName: "level5", Path: []string{"level5"}, - Arguments: []plan.ArgumentConfiguration{ - {Name: "first", SourceType: plan.FieldArgumentSource, RenderConfig: plan.RenderArgumentAsGraphQLValue}, - }, - }, - }, - expectedResponse: `{"data":{"level1":[{"level2":[{"level3":[{"level4":[{"level5":[{"value":"a"}]},{"level5":[{"value":"b"},{"value":"c"}]}]},{"level4":[{"level5":[{"value":"d"}]}]}]},{"level3":[{"level4":[{"level5":[{"value":"e"}]}]}]}]},{"level2":[{"level3":[{"level4":[{"level5":[{"value":"f"},{"value":"g"}]},{"level5":[{"value":"h"}]}]},{"level4":[{"level5":[{"value":"i"}]}]}]}]},{"level2":[{"level3":[{"level4":[{"level5":[{"value":"j"}]},{"level5":[{"value":"k"}]}]},{"level4":[{"level5":[{"value":"l"}]},{"level5":[{"value":"m"}]}]}]}]}]}}`, - expectedEstimatedCost: 211110, - // Actual cost with fractional multipliers: - // Level5: 13 items, 11 parents => multiplier 1.18 (13/11 = 1.181818...) - // Level4: 11 items, 7 parents => multiplier 1.57 (11/7 = 1.571428...) - // Level3: 7 items, 4 parents => multiplier 1.75 (7/4 = 1.75) - // Level2: 4 items, 3 parents => multiplier 1.33 (4/3 = 1.333...) - // Level1: 3 items, 1 parent => multiplier 3.0 - // - // Ideal calculation without rounding: - // cost = 3 * (1 + 1.33 * (1 + 1.75 * (1 + 1.57 * (1 + 1.18 * (1 + 1))))) - // = 50.806584 ~= 51 - // - // Current implementation: - // Level5: RoundToEven((1 + 1) * 1.18) = 2 - // Level4: RoundToEven((1 + 2) * 1.57) = 5 - // Level3: RoundToEven((1 + 5) * 1.75) = 10 (rounds to even) - // Level2: RoundToEven((1 + 10) * 1.33) = 15 - // Level1: RoundToEven((1 + 15) * 3.00) = 48 - // - // The compounding rounding error: 48 vs 51 (6% underestimate) - expectedActualCost: 48, - }, - computeCosts(), - )) - }) - - }) - t.Run("field merging with different nullability on non-overlapping union types", func(t *testing.T) { unionSchema := ` union Entity = User | Organization diff --git a/execution/engine/federation_caching_helpers_test.go b/execution/engine/federation_caching_helpers_test.go index 0a922e5b2d..0ec1cdbf20 100644 --- a/execution/engine/federation_caching_helpers_test.go +++ b/execution/engine/federation_caching_helpers_test.go @@ -184,10 +184,11 @@ func cachingTestQueryPath(name string) string { } type CacheLogEntry struct { - Operation string // "get", "set", "delete" - Keys []string // Keys involved in the operation - Hits []bool // For Get: whether each key was a hit (true) or miss (false) - Caller string // Fetch identity when debug enabled: "accounts: entity(User)" or "products: rootField(Query.topProducts)" + Operation string // "get", "set", "delete" + Keys []string // Keys involved in the operation + Hits []bool // For Get: whether each key was a hit (true) or miss (false) + TTL time.Duration // For Set: the TTL used + Caller string // Fetch identity when debug enabled: "accounts: entity(User)" or "products: rootField(Query.topProducts)" } // sortCacheLogKeys sorts the keys (and corresponding hits) in each cache log entry. @@ -289,6 +290,93 @@ func sortCacheLogKeysWithCaller(log []CacheLogEntry) []CacheLogEntry { return sorted } +// sortCacheLogEntries sorts both the entries (by operation+first key) and the keys within entries. +// Use this when log entry order is non-deterministic (e.g., split datasources executing in parallel). +func sortCacheLogEntries(log []CacheLogEntry) []CacheLogEntry { + sorted := sortCacheLogKeys(log) + sort.Slice(sorted, func(a, b int) bool { + if sorted[a].Operation != sorted[b].Operation { + return sorted[a].Operation < sorted[b].Operation + } + keyA, keyB := "", "" + if len(sorted[a].Keys) > 0 { + keyA = sorted[a].Keys[0] + } + if len(sorted[b].Keys) > 0 { + keyB = sorted[b].Keys[0] + } + return keyA < keyB + }) + return sorted +} + +// sortCacheLogKeysWithTTL is like sortCacheLogKeys but preserves the TTL field. +// Use this when assertions need to verify TTL values on set operations. +func sortCacheLogKeysWithTTL(log []CacheLogEntry) []CacheLogEntry { + sorted := make([]CacheLogEntry, len(log)) + for i, entry := range log { + if len(entry.Keys) <= 1 { + sorted[i] = CacheLogEntry{ + Operation: entry.Operation, + Keys: entry.Keys, + Hits: entry.Hits, + TTL: entry.TTL, + } + continue + } + + pairs := make([]struct { + key string + hit bool + }, len(entry.Keys)) + for j := range entry.Keys { + pairs[j].key = entry.Keys[j] + if entry.Hits != nil && j < len(entry.Hits) { + pairs[j].hit = entry.Hits[j] + } + } + sort.Slice(pairs, func(a, b int) bool { + return pairs[a].key < pairs[b].key + }) + sorted[i] = CacheLogEntry{ + Operation: entry.Operation, + Keys: make([]string, len(pairs)), + Hits: nil, + TTL: entry.TTL, + } + if len(entry.Hits) > 0 { + sorted[i].Hits = make([]bool, len(pairs)) + } + for j := range pairs { + sorted[i].Keys[j] = pairs[j].key + if sorted[i].Hits != nil { + sorted[i].Hits[j] = pairs[j].hit + } + } + } + return sorted +} + +// sortCacheLogEntriesWithTTL sorts both entries and keys while preserving TTL. +// Use this when entry order is non-deterministic and TTL values need to be verified. +func sortCacheLogEntriesWithTTL(log []CacheLogEntry) []CacheLogEntry { + sorted := sortCacheLogKeysWithTTL(log) + sort.Slice(sorted, func(a, b int) bool { + if sorted[a].Operation != sorted[b].Operation { + return sorted[a].Operation < sorted[b].Operation + } + keyA, keyB := "", "" + if len(sorted[a].Keys) > 0 { + keyA = sorted[a].Keys[0] + } + if len(sorted[b].Keys) > 0 { + keyB = sorted[b].Keys[0] + } + return keyA < keyB + }) + return sorted +} + type cacheEntry struct { data []byte expiresAt *time.Time @@ -405,6 +493,7 @@ func (f *FakeLoaderCache) Set(ctx context.Context, entries []*resolve.CacheEntry Operation: "set", Keys: keys, Hits: nil, // Set operations don't have hits/misses + TTL: ttl, Caller: caller, }) diff --git a/execution/engine/federation_caching_test.go b/execution/engine/federation_caching_test.go index e21ad535ef..d72152ac33 100644 --- a/execution/engine/federation_caching_test.go +++ b/execution/engine/federation_caching_test.go @@ -2515,3 +2515,342 @@ func TestFederationCaching_MutationSkipsL2Read(t *testing.T) { assert.Equal(t, 0, tracker.GetCount(accountsHost), "Step 3: should NOT call accounts subgraph (L2 cache hit)") }) } + +func TestRootFieldSplitByDatasource(t *testing.T) { + t.Run("two root fields same subgraph both cached", func(t *testing.T) { + defaultCache := NewFakeLoaderCache() + caches := map[string]resolve.LoaderCache{ + "default": defaultCache, + } + + tracker := newSubgraphCallTracker(http.DefaultTransport) + trackingClient := &http.Client{Transport: tracker} + + subgraphCachingConfigs := engine.SubgraphCachingConfigs{ + { + SubgraphName: "accounts", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + } + + setup := federationtesting.NewFederationSetup(addCachingGateway( + withCachingEnableART(false), + withCachingLoaderCache(caches), + withHTTPClient(trackingClient), + withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true}), + withSubgraphEntityCachingConfigs(subgraphCachingConfigs), + )) + t.Cleanup(setup.Close) + gqlClient := NewGraphqlClient(http.DefaultClient) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL) + accountsHost := accountsURLParsed.Host + + // First query - both fields miss cache, get set + defaultCache.ClearLog() + tracker.Reset() + resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterFirst := defaultCache.GetLog() + // Each cached root field gets its own fetch: get+set for me, get+set for cat + assert.Equal(t, 4, len(logAfterFirst), "Should have 4 cache operations (get+set for me, get+set for cat)") + + wantLogFirst := []CacheLogEntry{ + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{false}}, + {Operation: "set", Keys: []string{`{"__typename":"Query","field":"me"}`}}, + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"cat"}`}, Hits: []bool{false}}, + {Operation: "set", Keys: []string{`{"__typename":"Query","field":"cat"}`}}, + } + assert.Equal(t, sortCacheLogEntries(wantLogFirst), sortCacheLogEntries(logAfterFirst)) + + // Isolated root fields cause 2 separate calls to accounts subgraph + assert.Equal(t, 2, tracker.GetCount(accountsHost), "Should call accounts subgraph twice (once per root field)") + + // Second query - both fields hit cache + defaultCache.ClearLog() + tracker.Reset() + resp = gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterSecond := defaultCache.GetLog() + assert.Equal(t, 2, len(logAfterSecond), "Should have 2 cache get operations (both hits)") + + wantLogSecond := []CacheLogEntry{ + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{true}}, + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"cat"}`}, Hits: []bool{true}}, + } + assert.Equal(t, sortCacheLogEntries(wantLogSecond), sortCacheLogEntries(logAfterSecond)) + + assert.Equal(t, 0, tracker.GetCount(accountsHost), "Should not call accounts subgraph (both cache hits)") + }) + + t.Run("two root fields different TTLs", func(t *testing.T) { + defaultCache := NewFakeLoaderCache() + caches := map[string]resolve.LoaderCache{ + "default": defaultCache, + } + + subgraphCachingConfigs := engine.SubgraphCachingConfigs{ + { + SubgraphName: "accounts", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 10 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, + }, + }, + } + + setup := federationtesting.NewFederationSetup(addCachingGateway( + withCachingEnableART(false), + withCachingLoaderCache(caches), + withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true}), + withSubgraphEntityCachingConfigs(subgraphCachingConfigs), + )) + t.Cleanup(setup.Close) + gqlClient := NewGraphqlClient(http.DefaultClient) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + // First query populates cache + defaultCache.ClearLog() + resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterFirst := defaultCache.GetLog() + meKey := `{"__typename":"Query","field":"me"}` + catKey := `{"__typename":"Query","field":"cat"}` + wantLogFirst := []CacheLogEntry{ + {Operation: "get", Keys: []string{meKey}, Hits: []bool{false}}, // me: L2 miss + {Operation: "set", Keys: []string{meKey}, TTL: 10 * time.Second}, // me: cached with 10s TTL + {Operation: "get", Keys: []string{catKey}, Hits: []bool{false}}, // cat: L2 miss + {Operation: "set", Keys: []string{catKey}, TTL: 60 * time.Second}, // cat: cached with 60s TTL + } + assert.Equal(t, sortCacheLogEntriesWithTTL(wantLogFirst), sortCacheLogEntriesWithTTL(logAfterFirst)) + }) + + t.Run("mixed cached and uncached root fields", func(t *testing.T) { + defaultCache := NewFakeLoaderCache() + caches := map[string]resolve.LoaderCache{ + "default": defaultCache, + } + + tracker := newSubgraphCallTracker(http.DefaultTransport) + trackingClient := &http.Client{Transport: tracker} + + // Only me has caching, cat does not + subgraphCachingConfigs := engine.SubgraphCachingConfigs{ + { + SubgraphName: "accounts", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + } + + setup := federationtesting.NewFederationSetup(addCachingGateway( + withCachingEnableART(false), + withCachingLoaderCache(caches), + withHTTPClient(trackingClient), + withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true}), + withSubgraphEntityCachingConfigs(subgraphCachingConfigs), + )) + t.Cleanup(setup.Close) + gqlClient := NewGraphqlClient(http.DefaultClient) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL) + accountsHost := accountsURLParsed.Host + + // First query + defaultCache.ClearLog() + tracker.Reset() + resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterFirst := defaultCache.GetLog() + // Only "me" has caching: get (miss) + set + assert.Equal(t, 2, len(logAfterFirst), "Should have 2 cache operations (get+set for me only)") + + wantLogFirst := []CacheLogEntry{ + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{false}}, + {Operation: "set", Keys: []string{`{"__typename":"Query","field":"me"}`}}, + } + assert.Equal(t, sortCacheLogEntries(wantLogFirst), sortCacheLogEntries(logAfterFirst)) + + // accounts called twice: once for me (isolated planner), once for cat (separate planner) + assert.Equal(t, 2, tracker.GetCount(accountsHost), "Should call accounts subgraph twice (once per isolated root field)") + + // Second query - me hits cache, cat still fetches + defaultCache.ClearLog() + tracker.Reset() + resp = gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterSecond := defaultCache.GetLog() + assert.Equal(t, 1, len(logAfterSecond), "Should have 1 cache get (me hit)") + + wantLogSecond := []CacheLogEntry{ + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{true}}, + } + assert.Equal(t, sortCacheLogEntries(wantLogSecond), sortCacheLogEntries(logAfterSecond)) + + // Only cat (uncached) needs subgraph call + assert.Equal(t, 1, tracker.GetCount(accountsHost), "Should call accounts subgraph once (cat only, me from cache)") + }) + + t.Run("root field split with entity caching", func(t *testing.T) { + defaultCache := NewFakeLoaderCache() + caches := map[string]resolve.LoaderCache{ + "default": defaultCache, + } + + tracker := newSubgraphCallTracker(http.DefaultTransport) + trackingClient := &http.Client{Transport: tracker} + + subgraphCachingConfigs := engine.SubgraphCachingConfigs{ + { + SubgraphName: "accounts", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 30 * time.Second}, + }, + EntityCaching: plan.EntityCacheConfigurations{ + {TypeName: "User", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + { + SubgraphName: "products", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "topProducts", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + { + SubgraphName: "reviews", + EntityCaching: plan.EntityCacheConfigurations{ + {TypeName: "Product", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + } + + setup := federationtesting.NewFederationSetup(addCachingGateway( + withCachingEnableART(false), + withCachingLoaderCache(caches), + withHTTPClient(trackingClient), + withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true}), + withSubgraphEntityCachingConfigs(subgraphCachingConfigs), + )) + t.Cleanup(setup.Close) + gqlClient := NewGraphqlClient(http.DefaultClient) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL) + productsURLParsed, _ := url.Parse(setup.ProductsUpstreamServer.URL) + reviewsURLParsed, _ := url.Parse(setup.ReviewsUpstreamServer.URL) + accountsHost := accountsURLParsed.Host + productsHost := productsURLParsed.Host + reviewsHost := reviewsURLParsed.Host + + // Query that exercises root field split (me + cat from accounts) and entity caching (User from accounts) + query := `{ + me { id username } + cat { name } + topProducts { + name + reviews { + body + authorWithoutProvides { username } + } + } + }` + + // First query - all misses + defaultCache.ClearLog() + tracker.Reset() + resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, query, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"},"topProducts":[{"name":"Trilby","reviews":[{"body":"A highly effective form of birth control.","authorWithoutProvides":{"username":"Me"}}]},{"name":"Fedora","reviews":[{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","authorWithoutProvides":{"username":"Me"}}]}]}}`, string(resp)) + + // accounts: 2 for root field split (me + cat) + 1 for User entity resolution + assert.Equal(t, 3, tracker.GetCount(accountsHost), "accounts: once for me, once for cat, once for User entity") + assert.Equal(t, 1, tracker.GetCount(productsHost), "products: once for topProducts") + assert.Equal(t, 1, tracker.GetCount(reviewsHost), "reviews: once for Product entity") + + // Second query - all cache hits + defaultCache.ClearLog() + tracker.Reset() + resp = gqlClient.QueryString(ctx, setup.GatewayServer.URL, query, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"},"topProducts":[{"name":"Trilby","reviews":[{"body":"A highly effective form of birth control.","authorWithoutProvides":{"username":"Me"}}]},{"name":"Fedora","reviews":[{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits.","authorWithoutProvides":{"username":"Me"}}]}]}}`, string(resp)) + + // All subgraphs should be skipped on second query + assert.Equal(t, 0, tracker.GetCount(accountsHost), "accounts: all from cache") + assert.Equal(t, 0, tracker.GetCount(productsHost), "products: root field from cache") + assert.Equal(t, 0, tracker.GetCount(reviewsHost), "reviews: entity from cache") + }) + + t.Run("independent cache invalidation", func(t *testing.T) { + defaultCache := NewFakeLoaderCache() + caches := map[string]resolve.LoaderCache{ + "default": defaultCache, + } + + tracker := newSubgraphCallTracker(http.DefaultTransport) + trackingClient := &http.Client{Transport: tracker} + + subgraphCachingConfigs := engine.SubgraphCachingConfigs{ + { + SubgraphName: "accounts", + RootFieldCaching: plan.RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 30 * time.Second}, + }, + }, + } + + setup := federationtesting.NewFederationSetup(addCachingGateway( + withCachingEnableART(false), + withCachingLoaderCache(caches), + withHTTPClient(trackingClient), + withCachingOptionsFunc(resolve.CachingOptions{EnableL2Cache: true}), + withSubgraphEntityCachingConfigs(subgraphCachingConfigs), + )) + t.Cleanup(setup.Close) + gqlClient := NewGraphqlClient(http.DefaultClient) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + accountsURLParsed, _ := url.Parse(setup.AccountsUpstreamServer.URL) + accountsHost := accountsURLParsed.Host + + // First query - populate cache for both fields + resp := gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + // Invalidate only the "me" cache entry + err := defaultCache.Delete(ctx, []string{`{"__typename":"Query","field":"me"}`}) + require.NoError(t, err) + + // Second query - me should miss (re-fetch), cat should hit + defaultCache.ClearLog() + tracker.Reset() + resp = gqlClient.QueryString(ctx, setup.GatewayServer.URL, `{ me { id username } cat { name } }`, nil, t) + assert.Equal(t, `{"data":{"me":{"id":"1234","username":"Me"},"cat":{"name":"Pepper"}}}`, string(resp)) + + logAfterSecond := defaultCache.GetLog() + wantLog := []CacheLogEntry{ + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"me"}`}, Hits: []bool{false}}, // Invalidated + {Operation: "set", Keys: []string{`{"__typename":"Query","field":"me"}`}}, // Re-cached + {Operation: "get", Keys: []string{`{"__typename":"Query","field":"cat"}`}, Hits: []bool{true}}, // Still cached + } + assert.Equal(t, sortCacheLogEntries(wantLog), sortCacheLogEntries(logAfterSecond)) + + // Only me needs re-fetch, cat served from cache + assert.Equal(t, 1, tracker.GetCount(accountsHost), "Should call accounts once (me re-fetch only)") + }) +} diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go index 98d1f13708..315cdce91f 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_test.go @@ -395,11 +395,6 @@ func TestGraphQLDataSource(t *testing.T) { ), PostProcessing: DefaultPostProcessingConfiguration, Caching: resolve.FetchCacheConfiguration{ - Enabled: true, - CacheName: "default", - TTL: 30 * time.Second, - IncludeSubgraphHeaderPrefix: true, - // UseL1Cache defaults to false - root query fetches with RootQueryCacheKeyTemplate don't populate entity L1 cache CacheKeyTemplate: &resolve.RootQueryCacheKeyTemplate{ RootFields: []resolve.QueryField{ { @@ -791,14 +786,6 @@ func TestGraphQLDataSource(t *testing.T) { FieldNames: []string{"name", "primaryFunction", "friends"}, }, }, - FederationMetaData: plan.FederationMetaData{ - RootFieldCaching: plan.RootFieldCacheConfigurations{ - {TypeName: "Query", FieldName: "droid", CacheName: "default", TTL: 30 * time.Second, IncludeSubgraphHeaderPrefix: true}, - {TypeName: "Query", FieldName: "hero", CacheName: "default", TTL: 30 * time.Second, IncludeSubgraphHeaderPrefix: true}, - {TypeName: "Query", FieldName: "stringList", CacheName: "default", TTL: 30 * time.Second, IncludeSubgraphHeaderPrefix: true}, - {TypeName: "Query", FieldName: "nestedStringList", CacheName: "default", TTL: 30 * time.Second, IncludeSubgraphHeaderPrefix: true}, - }, - }, }, mustCustomConfiguration(t, ConfigurationInput{ Fetch: &FetchConfiguration{ diff --git a/v2/pkg/engine/plan/datasource_filter_visitor_test.go b/v2/pkg/engine/plan/datasource_filter_visitor_test.go index c385c23d26..0b8751f074 100644 --- a/v2/pkg/engine/plan/datasource_filter_visitor_test.go +++ b/v2/pkg/engine/plan/datasource_filter_visitor_test.go @@ -10,14 +10,16 @@ import ( "github.com/stretchr/testify/assert" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvalidation" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" "github.com/wundergraph/graphql-go-tools/v2/pkg/testing/permutations" ) type dsBuilder struct { - ds *dataSourceConfiguration[any] - behavior *DataSourcePlanningBehavior + ds *dataSourceConfiguration[any] + behavior *DataSourcePlanningBehavior + cacheKeyTemplate resolve.CacheKeyTemplate } func dsb() *dsBuilder { @@ -64,11 +66,17 @@ func (b *dsBuilder) WithBehavior(behavior DataSourcePlanningBehavior) *dsBuilder return b } +func (b *dsBuilder) CacheKeyTemplate(t resolve.CacheKeyTemplate) *dsBuilder { + b.cacheKeyTemplate = t + return b +} + func (b *dsBuilder) Schema(schema string) *dsBuilder { def := unsafeparser.ParseGraphqlDocumentString(schema) b.ds.factory = &FakeFactory[any]{ - upstreamSchema: &def, - behavior: b.behavior, + upstreamSchema: &def, + behavior: b.behavior, + cacheKeyTemplate: b.cacheKeyTemplate, } return b } @@ -76,8 +84,9 @@ func (b *dsBuilder) Schema(schema string) *dsBuilder { func (b *dsBuilder) SchemaMergedWithBase(schema string) *dsBuilder { def := unsafeparser.ParseGraphqlDocumentStringWithBaseSchema(schema) b.ds.factory = &FakeFactory[any]{ - upstreamSchema: &def, - behavior: b.behavior, + upstreamSchema: &def, + behavior: b.behavior, + cacheKeyTemplate: b.cacheKeyTemplate, } return b } @@ -101,6 +110,11 @@ func (b *dsBuilder) Id(id string) *dsBuilder { b.ds.id = id return b } + +func (b *dsBuilder) Name(name string) *dsBuilder { + b.ds.name = name + return b +} func (b *dsBuilder) DS() DataSource { if err := b.ds.DataSourceMetadata.Init(); err != nil { panic(err) diff --git a/v2/pkg/engine/plan/path_builder_visitor.go b/v2/pkg/engine/plan/path_builder_visitor.go index b66b41375a..41ecb52d2f 100644 --- a/v2/pkg/engine/plan/path_builder_visitor.go +++ b/v2/pkg/engine/plan/path_builder_visitor.go @@ -110,9 +110,13 @@ type selectionSetTypeInfo struct { } type objectFetchConfiguration struct { - filter *resolve.SubscriptionFilter - planner DataSourceFetchPlanner - isSubscription bool + filter *resolve.SubscriptionFilter + planner DataSourceFetchPlanner + isSubscription bool + // isolatedRootField marks planners for cached query root fields that must + // not merge with other root fields. Set in handlePlanningField; checked in + // planWithExistingPlanners to prevent other fields from joining this planner. + isolatedRootField bool fieldRef int fieldDefinitionRef int sourceID string @@ -560,14 +564,24 @@ func (c *pathBuilderVisitor) handlePlanningField(fieldRef int, typeName, fieldNa } isMutationRoot := c.isMutationRoot(currentPath) + isCachedQueryRoot := c.isCachedQueryRootField(currentPath, typeName, fieldName, ds) var ( plannerIdx int planned bool ) - if isMutationRoot { + if isMutationRoot || isCachedQueryRoot { + // Mutations always need separate planners for sequential execution. + // Cached query root fields need separate planners so each fetch gets + // its own cache configuration (TTL, cache name). Without isolation, + // configureFetchCaching sees mixed root fields and disables L2 caching. plannerIdx, planned = c.addNewPlanner(fieldRef, typeName, fieldName, currentPath, parentPath, isMutationRoot, ds) + if planned && isCachedQueryRoot { + // Mark this planner as isolated so planWithExistingPlanners won't + // merge other root fields into it (see guard in that function). + c.planners[plannerIdx].ObjectFetchConfiguration().isolatedRootField = true + } } else { plannerIdx, planned = c.planWithExistingPlanners(fieldRef, typeName, fieldName, currentPath, parentPath, precedingParentPath, suggestion) if !planned { @@ -766,6 +780,16 @@ func (c *pathBuilderVisitor) planWithExistingPlanners(fieldRef int, typeName, fi isRootNode := suggestion.IsRootNode isChildNode := !isRootNode + // Don't merge other query root fields into isolated planners (cached root fields). + // We check parentPath (not isRootNode) because entity types like Product are + // also datasource root nodes — isRootNode would incorrectly block nested entity + // fields from merging into the planner that needs them. + // isParentPathIsRootOperationPath checks if parentPath is "query"/"mutation"/"subscription", + // ensuring only top-level query fields are prevented from merging. + if c.isParentPathIsRootOperationPath(parentPath) && plannerConfig.ObjectFetchConfiguration().isolatedRootField { + continue + } + if c.secondaryRun && plannerConfig.HasPath(currentPath) { // on the secondary run we need to process only new fields added by the first run return plannerIdx, true @@ -1305,6 +1329,34 @@ func (c *pathBuilderVisitor) isMutationRoot(path string) bool { return strings.Count(path, ".") == 1 } +// isCachedQueryRootField returns true when the field is a direct child of Query +// and has root field caching configured on the datasource. Such fields must be +// isolated into their own planner to get independent cache configs per fetch. +// +// This mirrors the mutation pattern (isMutationRoot) but only applies to query +// fields with explicit RootFieldCacheConfiguration. Without isolation, multiple +// root fields from the same datasource merge into one planner/fetch, and +// configureFetchCaching sees mixed cache configs and disables L2 caching. +func (c *pathBuilderVisitor) isCachedQueryRootField(currentPath, typeName, fieldName string, ds DataSource) bool { + // When entity caching is globally disabled, no isolation needed + if c.plannerConfiguration.DisableEntityCaching { + return false + } + // Only applies to Query operations, not mutations or subscriptions + root := c.walker.Ancestors[0] + rootOperationType := c.operation.OperationDefinitions[root.Ref].OperationType + if rootOperationType != ast.OperationTypeQuery { + return false + } + // Only direct children of the root (e.g. "query.me" has exactly one dot) + if strings.Count(currentPath, ".") != 1 { + return false + } + // Check if this specific field has a cache config on its datasource + fedConfig := ds.FederationConfiguration() + return fedConfig.RootFieldCacheConfig(typeName, fieldName) != nil +} + func (c *pathBuilderVisitor) isNotOperationDefinitionRoot() bool { // potentially this check is not needed, because we should not have root fragments definitions // at this stage of planning diff --git a/v2/pkg/engine/plan/planner_test.go b/v2/pkg/engine/plan/planner_test.go index 2f3886a227..23ba6942c5 100644 --- a/v2/pkg/engine/plan/planner_test.go +++ b/v2/pkg/engine/plan/planner_test.go @@ -8,6 +8,7 @@ import ( "reflect" "slices" "testing" + "time" "github.com/jensneuse/abstractlogger" "github.com/kylelemons/godebug/diff" @@ -815,6 +816,383 @@ func TestPlanner_Plan(t *testing.T) { assert.Equal(t, plan2Expected, plan2) }) + + // Root field caching isolation tests + // When a root field has caching configured, the planner must isolate it into its own + // planner/fetch so it gets an independent cache config (TTL, cache name, etc.). + // This uses the same pattern as mutations: cached root fields skip planWithExistingPlanners + // and go straight to addNewPlanner. Other fields are prevented from merging into + // isolated planners via the isolatedRootField flag. + t.Run("root field caching isolation", func(t *testing.T) { + const schema = ` + type Query { + me: User + cat: Cat + user(id: ID!): User + } + type User { + id: ID! + username: String! + } + type Cat { + name: String! + } + ` + // Minimal CacheKeyTemplate to enable configureFetchCaching to populate cache config. + // Without this, configureFetchCaching bails early (CacheKeyTemplate == nil). + cacheKeyTpl := &resolve.RootQueryCacheKeyTemplate{} + + // Two cached root fields produce parallel, independent fetches (FetchID 0 and 1, no DependsOnFetchIDs). + // Each fetch gets its own cache config (Enabled, CacheName, TTL). + t.Run("two cached root fields get separate parallel fetches with correct cache configs", test(schema, + `query Q { me { id username } cat { name } }`, "Q", + &SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + RawFetches: []*resolve.FetchItem{ + { + Fetch: &resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + Enabled: true, + CacheName: "users", + TTL: 30 * time.Second, + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + { + Fetch: &resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + }, + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + Enabled: true, + CacheName: "pets", + TTL: 60 * time.Second, + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + }, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("me"), + Value: &resolve.Object{ + Path: []string{"me"}, + Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{Path: []string{"id"}}, + }, + { + Name: []byte("username"), + Value: &resolve.String{Path: []string{"username"}}, + }, + }, + }, + }, + { + Name: []byte("cat"), + Value: &resolve.Object{ + Path: []string{"cat"}, + Nullable: true, + TypeName: "Cat", + PossibleTypes: map[string]struct{}{"Cat": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{Path: []string{"name"}}, + }, + }, + }, + }, + }, + }, + }, + }, + Configuration{ + DataSources: []DataSource{dsb(). + Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{MergeAliasedRootNodes: true}). + CacheKeyTemplate(cacheKeyTpl). + RootNode("Query", "me", "cat"). + ChildNode("User", "id", "username"). + ChildNode("Cat", "name"). + Schema(schema). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "users", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "pets", TTL: 60 * time.Second}, + } + }). + DS()}, + DisableResolveFieldPositions: true, + DisableIncludeInfo: true, + DisableEntityCaching: false, + }, + )) + + // Cached "me" is isolated from uncached "user" — each gets its own fetch. + // Only the cached field gets Enabled:true. + t.Run("cached field isolated from uncached field - only cached gets L2", test(schema, + `query Q { me { id } user(id: "1") { username } }`, "Q", + &SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + RawFetches: []*resolve.FetchItem{ + { + Fetch: &resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 0, + }, + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + Enabled: true, + CacheName: "default", + TTL: 30 * time.Second, + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + { + Fetch: &resolve.SingleFetch{ + FetchDependencies: resolve.FetchDependencies{ + FetchID: 1, + }, + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + }, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("me"), + Value: &resolve.Object{ + Path: []string{"me"}, + Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{Path: []string{"id"}}, + }, + }, + }, + }, + { + Name: []byte("user"), + Value: &resolve.Object{ + Path: []string{"user"}, + Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("username"), + Value: &resolve.String{Path: []string{"username"}}, + }, + }, + }, + }, + }, + }, + }, + }, + Configuration{ + DataSources: []DataSource{dsb(). + Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{MergeAliasedRootNodes: true}). + CacheKeyTemplate(cacheKeyTpl). + RootNode("Query", "me", "user"). + ChildNode("User", "id", "username"). + Schema(schema). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + } + }). + DS()}, + DisableResolveFieldPositions: true, + DisableIncludeInfo: true, + }, + )) + + // DisableEntityCaching skips isolation — fields merge into one fetch, L2 disabled. + t.Run("DisableEntityCaching - fields merge and no L2 caching", test(schema, + `query Q { me { id username } cat { name } }`, "Q", + &SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + RawFetches: []*resolve.FetchItem{ + { + Fetch: &resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + }, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("me"), + Value: &resolve.Object{ + Path: []string{"me"}, + Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{Path: []string{"id"}}, + }, + { + Name: []byte("username"), + Value: &resolve.String{Path: []string{"username"}}, + }, + }, + }, + }, + { + Name: []byte("cat"), + Value: &resolve.Object{ + Path: []string{"cat"}, + Nullable: true, + TypeName: "Cat", + PossibleTypes: map[string]struct{}{"Cat": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{Path: []string{"name"}}, + }, + }, + }, + }, + }, + }, + }, + }, + Configuration{ + DataSources: []DataSource{dsb(). + Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{MergeAliasedRootNodes: true}). + CacheKeyTemplate(cacheKeyTpl). + RootNode("Query", "me", "cat"). + ChildNode("User", "id", "username"). + ChildNode("Cat", "name"). + Schema(schema). + WithMetadata(func(data *FederationMetaData) { + data.RootFieldCaching = RootFieldCacheConfigurations{ + {TypeName: "Query", FieldName: "me", CacheName: "default", TTL: 30 * time.Second}, + {TypeName: "Query", FieldName: "cat", CacheName: "default", TTL: 60 * time.Second}, + } + }). + DS()}, + DisableResolveFieldPositions: true, + DisableIncludeInfo: true, + DisableEntityCaching: true, + }, + )) + + // No RootFieldCaching at all — fields merge normally, L2 disabled. + t.Run("no caching configured - fields merge normally", test(schema, + `query Q { me { id username } cat { name } }`, "Q", + &SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + RawFetches: []*resolve.FetchItem{ + { + Fetch: &resolve.SingleFetch{ + FetchConfiguration: resolve.FetchConfiguration{ + DataSource: &FakeDataSource{&StatefulSource{}}, + Caching: resolve.FetchCacheConfiguration{ + CacheKeyTemplate: cacheKeyTpl, + }, + }, + DataSourceIdentifier: []byte("plan.FakeDataSource"), + }, + }, + }, + Data: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("me"), + Value: &resolve.Object{ + Path: []string{"me"}, + Nullable: true, + TypeName: "User", + PossibleTypes: map[string]struct{}{"User": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("id"), + Value: &resolve.Scalar{Path: []string{"id"}}, + }, + { + Name: []byte("username"), + Value: &resolve.String{Path: []string{"username"}}, + }, + }, + }, + }, + { + Name: []byte("cat"), + Value: &resolve.Object{ + Path: []string{"cat"}, + Nullable: true, + TypeName: "Cat", + PossibleTypes: map[string]struct{}{"Cat": {}}, + Fields: []*resolve.Field{ + { + Name: []byte("name"), + Value: &resolve.String{Path: []string{"name"}}, + }, + }, + }, + }, + }, + }, + }, + }, + Configuration{ + DataSources: []DataSource{dsb(). + Id("accounts"). + WithBehavior(DataSourcePlanningBehavior{MergeAliasedRootNodes: true}). + CacheKeyTemplate(cacheKeyTpl). + RootNode("Query", "me", "cat"). + ChildNode("User", "id", "username"). + ChildNode("Cat", "name"). + Schema(schema). + DS()}, + DisableResolveFieldPositions: true, + DisableIncludeInfo: true, + }, + )) + }) } var expectedMyHeroPlan = &SynchronousResponsePlan{ @@ -1010,8 +1388,9 @@ func (s *StatefulSource) Start() { } type FakeFactory[T any] struct { - upstreamSchema *ast.Document - behavior *DataSourcePlanningBehavior + upstreamSchema *ast.Document + behavior *DataSourcePlanningBehavior + cacheKeyTemplate resolve.CacheKeyTemplate } func (f *FakeFactory[T]) UpstreamSchema(_ DataSourceConfiguration[T]) (*ast.Document, bool) { @@ -1029,9 +1408,10 @@ func (f *FakeFactory[T]) Planner(_ abstractlogger.Logger) DataSourcePlanner[T] { source := &StatefulSource{} go source.Start() return &FakePlanner[T]{ - source: source, - upstreamSchema: f.upstreamSchema, - behavior: f.behavior, + source: source, + upstreamSchema: f.upstreamSchema, + behavior: f.behavior, + cacheKeyTemplate: f.cacheKeyTemplate, } } @@ -1040,10 +1420,11 @@ func (f *FakeFactory[T]) Context() context.Context { } type FakePlanner[T any] struct { - id int - source *StatefulSource - upstreamSchema *ast.Document - behavior *DataSourcePlanningBehavior + id int + source *StatefulSource + upstreamSchema *ast.Document + behavior *DataSourcePlanningBehavior + cacheKeyTemplate resolve.CacheKeyTemplate } func (f *FakePlanner[T]) ID() int { @@ -1064,11 +1445,15 @@ func (f *FakePlanner[T]) Register(visitor *Visitor, _ DataSourceConfiguration[T] } func (f *FakePlanner[T]) ConfigureFetch() resolve.FetchConfiguration { - return resolve.FetchConfiguration{ + cfg := resolve.FetchConfiguration{ DataSource: &FakeDataSource{ source: f.source, }, } + if f.cacheKeyTemplate != nil { + cfg.Caching.CacheKeyTemplate = f.cacheKeyTemplate + } + return cfg } func (f *FakePlanner[T]) ConfigureSubscription() SubscriptionConfiguration {