Skip to content

Commit 2eca1ab

Browse files
authored
feat: support costs on arguments of directives (#1465)
I have opted for the simple base layer without recursion. Directives could have input objects passed to arguments, where fields could have costs. We do not handle such recursion here. I settled on the arguments level of directives.
1 parent fc6af0f commit 2eca1ab

8 files changed

Lines changed: 326 additions & 67 deletions

File tree

execution/engine/execution_engine_cost_test.go

Lines changed: 202 additions & 40 deletions
Large diffs are not rendered by default.

execution/engine/execution_engine_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4671,7 +4671,7 @@ func TestExecutionEngine_Execute(t *testing.T) {
46714671
var ds1CostConfig *plan.DataSourceCostConfig
46724672
if opts.includeCostConfig {
46734673
ds1CostConfig = &plan.DataSourceCostConfig{
4674-
Weights: map[plan.FieldCoordinate]*plan.FieldWeight{
4674+
Weights: map[plan.FieldCoordinate]*plan.FieldCost{
46754675
{TypeName: "Query", FieldName: "accounts"}: {HasWeight: true, Weight: 5},
46764676
{TypeName: "User", FieldName: "some"}: {HasWeight: true, Weight: 2},
46774677
{TypeName: "Admin", FieldName: "some"}: {HasWeight: true, Weight: 3},
@@ -4686,7 +4686,7 @@ func TestExecutionEngine_Execute(t *testing.T) {
46864686
var ds2CostConfig *plan.DataSourceCostConfig
46874687
if opts.includeCostConfig {
46884688
ds2CostConfig = &plan.DataSourceCostConfig{
4689-
Weights: map[plan.FieldCoordinate]*plan.FieldWeight{
4689+
Weights: map[plan.FieldCoordinate]*plan.FieldCost{
46904690
{TypeName: "User", FieldName: "name"}: {HasWeight: true, Weight: 2},
46914691
{TypeName: "User", FieldName: "title"}: {HasWeight: true, Weight: 4},
46924692
{TypeName: "Admin", FieldName: "adminName"}: {HasWeight: true, Weight: 3},

execution/engine/testdata/full_introspection.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,25 @@
641641
}
642642
]
643643
},
644+
{
645+
"name": "approx",
646+
"description": "",
647+
"locations": [
648+
"FIELD_DEFINITION"
649+
],
650+
"args": [
651+
{
652+
"name": "tolerance",
653+
"description": "",
654+
"type": {
655+
"kind": "SCALAR",
656+
"name": "Int",
657+
"ofType": null
658+
},
659+
"defaultValue": "1"
660+
}
661+
]
662+
},
644663
{
645664
"name": "include",
646665
"description": "Directs the executor to include this field or fragment only when the argument is true.",

execution/engine/testdata/full_introspection_with_deprecated.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,25 @@
675675
}
676676
]
677677
},
678+
{
679+
"name": "approx",
680+
"description": "",
681+
"locations": [
682+
"FIELD_DEFINITION"
683+
],
684+
"args": [
685+
{
686+
"name": "tolerance",
687+
"description": "",
688+
"type": {
689+
"kind": "SCALAR",
690+
"name": "Int",
691+
"ofType": null
692+
},
693+
"defaultValue": "1"
694+
}
695+
]
696+
},
678697
{
679698
"name": "include",
680699
"description": "Directs the executor to include this field or fragment only when the argument is true.",

execution/engine/testdata/full_introspection_with_typenames.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,28 @@
725725
}
726726
]
727727
},
728+
{
729+
"__typename": "__Directive",
730+
"name": "approx",
731+
"description": "",
732+
"locations": [
733+
"FIELD_DEFINITION"
734+
],
735+
"args": [
736+
{
737+
"__typename": "__InputValue",
738+
"name": "tolerance",
739+
"description": "",
740+
"type": {
741+
"__typename": "__Type",
742+
"kind": "SCALAR",
743+
"name": "Int",
744+
"ofType": null
745+
},
746+
"defaultValue": "1"
747+
}
748+
]
749+
},
728750
{
729751
"__typename": "__Directive",
730752
"name": "include",

v2/pkg/engine/plan/cost.go

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ https://ibm.github.io/graphql-specs/cost-spec.html
1414
1515
It builds on top of IBM spec for @cost and @listSize directive with a few changes.
1616
17-
* We use Int! for weights instead of floats packed in String!.
17+
* We use the Int! type for weights.
1818
* When weight is specified for the type and a field returns the list of that type,
1919
this weight (along with children's costs) is multiplied too.
2020
21-
TODO: Weights on arguments of directives
21+
Weights on arguments of directives are supported. If an argument is of InputObject's type,
22+
then the weight from its fields is not counted.
2223
2324
*/
2425

@@ -40,8 +41,8 @@ import (
4041
const DefaultEnumScalarWeight = 0
4142
const DefaultObjectWeight = 1
4243

43-
// FieldWeight defines cost configuration for a specific field of an object or input object.
44-
type FieldWeight struct {
44+
// FieldCost defines cost configuration for a specific field of an object or input object.
45+
type FieldCost struct {
4546

4647
// Weight is the cost of this field definition. It could be negative or zero.
4748
// Should be used only if HasWeight is true.
@@ -53,6 +54,10 @@ type FieldWeight struct {
5354
// ArgumentWeights maps an argument name to its weight.
5455
// Location: ARGUMENT_DEFINITION
5556
ArgumentWeights map[string]int
57+
58+
// DirectiveArgumentWeights maps a directive.argument coords to its weight.
59+
// Populated by composition from @cost on directive argument definitions.
60+
DirectiveArgumentWeights map[string]int
5661
}
5762

5863
// FieldListSize contains parsed data from the @listSize directive for an object field.
@@ -118,7 +123,7 @@ func (ls *FieldListSize) multiplier(arguments map[string]ArgumentInfo, vars *ast
118123
type DataSourceCostConfig struct {
119124
// Weights maps field coordinate to its weights. Cannot be on fields of interfaces.
120125
// Location: FIELD_DEFINITION, INPUT_FIELD_DEFINITION
121-
Weights map[FieldCoordinate]*FieldWeight
126+
Weights map[FieldCoordinate]*FieldCost
122127

123128
// ListSizes maps field coordinates to their respective list size configurations.
124129
// Location: FIELD_DEFINITION
@@ -129,19 +134,12 @@ type DataSourceCostConfig struct {
129134
// Weight assigned to the field or argument definitions overrides the weight of type definition.
130135
// Location: ENUM, OBJECT, SCALAR
131136
Types map[string]int
132-
133-
// Arguments on directives is a special case. They use a special kind of coordinate:
134-
// directive name + argument name. That should be the key mapped to the weight.
135-
//
136-
// Directives can be used on [input] object fields and arguments of fields. This creates
137-
// mutual recursion between them; it complicates cost calculation.
138-
// We avoid them intentionally in the first iteration.
139137
}
140138

141139
// NewDataSourceCostConfig creates a new cost config with defaults
142140
func NewDataSourceCostConfig() *DataSourceCostConfig {
143141
return &DataSourceCostConfig{
144-
Weights: make(map[FieldCoordinate]*FieldWeight),
142+
Weights: make(map[FieldCoordinate]*FieldCost),
145143
ListSizes: make(map[FieldCoordinate]*FieldListSize),
146144
Types: make(map[string]int),
147145
}
@@ -238,7 +236,7 @@ type inputObjectField struct {
238236

239237
// inputFieldsCost computes the cost of input object fields from the variable value.
240238
// It handles both single objects and arrays of objects.
241-
func (arg *ArgumentInfo) inputFieldsCost(variables *astjson.Value, weights map[FieldCoordinate]*FieldWeight) int {
239+
func (arg *ArgumentInfo) inputFieldsCost(variables *astjson.Value, weights map[FieldCoordinate]*FieldCost) int {
242240
if !arg.hasVariable {
243241
return 0
244242
}
@@ -262,8 +260,8 @@ func (arg *ArgumentInfo) inputFieldsCost(variables *astjson.Value, weights map[F
262260
return 0
263261
}
264262

265-
func (node *CostTreeNode) maxWeightImplementingField(config *DataSourceCostConfig, fieldName string) *FieldWeight {
266-
var maxWeight *FieldWeight
263+
func (node *CostTreeNode) maxWeightImplementingField(config *DataSourceCostConfig, fieldName string) *FieldCost {
264+
var maxWeight *FieldCost
267265
for _, implTypeName := range node.implementingTypeNames {
268266
// Get the cost config for the field of an implementing type.
269267
coord := FieldCoordinate{implTypeName, fieldName}
@@ -330,6 +328,29 @@ func (node *CostTreeNode) sizedFieldImplementingFields(config *DataSourceCostCon
330328
return result
331329
}
332330

331+
// maxDirectiveArgumentWeightsImplementingFields returns the union of DirectiveArgumentWeights
332+
// from implementing types' field definitions. For each directive.argument pair, it takes the
333+
// maximum weight across all implementing types.
334+
func (node *CostTreeNode) maxDirectiveArgumentWeightsImplementingFields(config *DataSourceCostConfig, fieldName string) map[string]int {
335+
var result map[string]int
336+
for _, implTypeName := range node.implementingTypeNames {
337+
coords := FieldCoordinate{implTypeName, fieldName}
338+
fw := config.Weights[coords]
339+
if fw == nil || len(fw.DirectiveArgumentWeights) == 0 {
340+
continue
341+
}
342+
if result == nil {
343+
result = make(map[string]int)
344+
}
345+
for dirArg, weight := range fw.DirectiveArgumentWeights {
346+
if existing, ok := result[dirArg]; !ok || weight > existing {
347+
result[dirArg] = weight
348+
}
349+
}
350+
}
351+
return result
352+
}
353+
333354
// cost calculates the estimated/actual cost of this node and all descendants.
334355
//
335356
// defaultListSize designates the mode of operation.
@@ -382,7 +403,7 @@ func (node *CostTreeNode) cost(configs map[DSHash]*DataSourceCostConfig, variabl
382403
//
383404
// fieldCost is the weight of this field or its returned type
384405
// argsCost is the sum of argument weights and input fields used on this field.
385-
// Weights on directives ignored for now.
406+
// directiveCost is the sum of directive argument weights.
386407
//
387408
// defaultListSize designates the mode of operation.
388409
// When it is positive, then its value is used as a fallback value of list sizes for the estimated cost.
@@ -391,7 +412,7 @@ func (node *CostTreeNode) cost(configs map[DSHash]*DataSourceCostConfig, variabl
391412
// When estimating cost, it picks the highest multiplier among different data sources.
392413
// Also, it picks the maximum field weight of implementing types and then
393414
// the maximum among slicing arguments.
394-
func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostConfig, variables *astjson.Value, defaultListSize int, actualListSizes map[string]int) (fieldCost, argsCost, directiveCost int, multiplier float64) {
415+
func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostConfig, variables *astjson.Value, defaultListSize int, actualListSizes map[string]int) (fieldCost, argsCost, directivesCost int, multiplier float64) {
395416
if len(node.dataSourceHashes) <= 0 {
396417
// no data source is responsible for this field
397418
return
@@ -400,7 +421,7 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC
400421
parent := node.parent
401422
fieldCost = 0
402423
argsCost = 0
403-
directiveCost = 0
424+
directivesCost = 0
404425
multiplier = 0
405426

406427
isEstimation := defaultListSize > 0
@@ -479,6 +500,18 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC
479500
}
480501
}
481502

503+
// Directive weights: sum from the field's own DirectiveArgumentWeights,
504+
// or from implementing types when the enclosing type is abstract.
505+
if node.isEnclosingTypeAbstract && parent.returnsAbstractType {
506+
for _, weight := range parent.maxDirectiveArgumentWeightsImplementingFields(dsCostConfig, node.fieldCoords.FieldName) {
507+
directivesCost += weight
508+
}
509+
} else if fieldWeight != nil {
510+
for _, weight := range fieldWeight.DirectiveArgumentWeights {
511+
directivesCost += weight
512+
}
513+
}
514+
482515
if !node.returnsListType || !isEstimation {
483516
continue
484517
}
@@ -571,7 +604,7 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC
571604
func inputObjectCost(
572605
typeName string,
573606
value *astjson.Object,
574-
weights map[FieldCoordinate]*FieldWeight,
607+
weights map[FieldCoordinate]*FieldCost,
575608
types map[FieldCoordinate]inputObjectField) int {
576609
if value == nil {
577610
return 0

v2/pkg/engine/plan/cost_visitor.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func (v *CostVisitor) EnterField(fieldRef int) {
101101
}
102102

103103
isEnclosingTypeAbstract := v.Walker.EnclosingTypeDefinition.Kind.IsAbstractType()
104-
// Create a skeleton node. dataSourceHashes will be filled in leaveFieldCost
104+
// Partially filled node. dataSourceHashes will be filled in leaveFieldCost
105105
node := CostTreeNode{
106106
fieldRef: fieldRef,
107107
fieldCoords: FieldCoordinate{typeName, fieldName},
@@ -115,7 +115,7 @@ func (v *CostVisitor) EnterField(fieldRef int) {
115115
jsonPath: jsonPath,
116116
}
117117

118-
// Attach to parent
118+
// Attach to the parent
119119
if len(v.stack) > 0 {
120120
parent := v.stack[len(v.stack)-1]
121121
parent.children = append(parent.children, &node)

v2/pkg/starwars/testdata/star_wars.graphql

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ schema {
88

99
directive @testDeprecated(okArg: String deprecatedArg: String @deprecated(reason: "no such arg")) on FIELD_DEFINITION
1010

11+
# Used to test costs on arguments of directives:
12+
# the tolerance argument has "@cost(weight: -5)" defined in tests.
13+
directive @approx(tolerance: Int = 1) on FIELD_DEFINITION
14+
1115
type Query {
1216
hero: Character @deprecated
13-
droid(id: ID!): Droid
14-
search(name: String!): SearchResult
17+
droid(id: ID!): Droid @approx(tolerance: null)
18+
search(name: String!): SearchResult @approx
1519
searchResults: [SearchResult]
1620
}
1721

@@ -52,7 +56,7 @@ type Human implements Character {
5256
}
5357

5458
type Droid implements Character {
55-
name: String!
59+
name: String! @approx
5660
primaryFunction: String!
5761
friends: [Character]
5862
}

0 commit comments

Comments
 (0)