diff --git a/execution/engine/execution_engine_cost_test.go b/execution/engine/execution_engine_cost_test.go index f9f6759b2d..e24d268365 100644 --- a/execution/engine/execution_engine_cost_test.go +++ b/execution/engine/execution_engine_cost_test.go @@ -291,6 +291,44 @@ func TestExecutionEngine_Cost(t *testing.T) { computeCosts(), )) + // Regression test for the abstract field without __typename bug recordObjectTypeStats). + // When the subgraph resolves a single (non-list) abstract field and does NOT return __typename, + // we must still record one occurrence for that field's path, falling back to the declared + // abstract type name in actual costs. + t.Run("single abstract field without __typename takes into account implementing types", runWithoutError( + ExecutionEngineTestCase{ + schema: graphql.StarwarsSchema(t), + operation: func(t *testing.T) graphql.Request { + return graphql.Request{ + Query: `{ hero { ... on Human { height } } }`, + } + }, + dataSources: []plan.DataSource{ + mustGraphqlDataSourceConfiguration(t, "id", + mustFactory(t, + testNetHttpClient(t, roundTripperTestCase{ + expectedHost: "example.com", expectedPath: "/", expectedBody: "", + // No __typename returned for the abstract hero field. + sendResponseBody: `{"data":{"hero":{"height":"12"}}}`, + sendStatusCode: 200, + }), + ), + &plan.DataSourceMetadata{RootNodes: rootNodes, ChildNodes: childNodes, CostConfig: &plan.DataSourceCostConfig{ + Weights: map[plan.FieldCoordinate]*plan.FieldCost{}, + Types: map[string]int{ + "Human": 13, + "Droid": 7, + }, + }}, + customConfig, + ), + }, + expectedEstimatedCost: intPtr(13), // Query.hero(13) + expectedActualCost: intPtr(13), // Query.hero(13) + }, + computeCosts(), + )) + t.Run("field returning a list of abstracts with partial fragments considers only actual type counts", runWithoutError( ExecutionEngineTestCase{ schema: graphql.StarwarsSchema(t), diff --git a/v2/pkg/engine/resolve/resolvable.go b/v2/pkg/engine/resolve/resolvable.go index 9ca6043cc2..13695e1e4b 100644 --- a/v2/pkg/engine/resolve/resolvable.go +++ b/v2/pkg/engine/resolve/resolvable.go @@ -1024,7 +1024,7 @@ func (r *Resolvable) walkArray(arr *Array, value *astjson.Value) bool { // that resolves an abstract (interface/union) field. func (r *Resolvable) recordObjectTypeStats(obj *Object, typeName []byte) { // An array item Object has an empty Path - if len(obj.Path) == 0 || typeName == nil || !obj.isAbstract() { + if len(obj.Path) == 0 || !obj.isAbstract() { return } pathKey := r.currentFieldPath() @@ -1033,7 +1033,12 @@ func (r *Resolvable) recordObjectTypeStats(obj *Object, typeName []byte) { if stats.TypeNames == nil { stats.TypeNames = make(map[string]int, 1) } - stats.TypeNames[string(typeName)]++ + // Fall back to the declared abstract type name when the subgraph did not return __typename. + name := obj.TypeName + if typeName != nil { + name = string(typeName) + } + stats.TypeNames[name]++ r.typeNameStats[pathKey] = stats }