Repository: jensneuse/graphql-go-tools
Pull request #370 (2022-05-17 06:22:46)

Feature/add repeatable directives


Patch:
diff --git a/pkg/ast/ast_directive_definition.go b/pkg/ast/ast_directive_definition.go
index 388c6c1a..562489b2 100644
--- a/pkg/ast/ast_directive_definition.go
+++ b/pkg/ast/ast_directive_definition.go
@@ -19,6 +19,12 @@ type DirectiveDefinition struct {
 	ArgumentsDefinition     InputValueDefinitionList // optional, e.g. (if: Boolean)
 	On                      position.Position        // on
 	DirectiveLocations      DirectiveLocations       // e.g. FIELD
+	Repeatable              Repeatable
+}
+
+type Repeatable struct {
+	IsRepeatable bool
+	Position     position.Position
 }
 
 func (d *Document) DirectiveDefinitionNameBytes(ref int) ByteSlice {
diff --git a/pkg/astparser/parser.go b/pkg/astparser/parser.go
index cc797a2f..9bd0e33e 100644
--- a/pkg/astparser/parser.go
+++ b/pkg/astparser/parser.go
@@ -1183,6 +1183,12 @@ func (p *Parser) parseDirectiveDefinition(description *ast.Description) {
 		directiveDefinition.ArgumentsDefinition = p.parseInputValueDefinitionList(keyword.RPAREN)
 		directiveDefinition.HasArgumentsDefinitions = len(directiveDefinition.ArgumentsDefinition.Refs) > 0
 	}
+
+	if p.peekEqualsIdentKey(identkeyword.REPEATABLE) {
+		directiveDefinition.Repeatable.IsRepeatable = true
+		directiveDefinition.Repeatable.Position = p.mustReadIdentKey(identkeyword.REPEATABLE).TextPosition
+	}
+
 	directiveDefinition.On = p.mustReadIdentKey(identkeyword.ON).TextPosition
 	p.parseDirectiveLocations(&directiveDefinition.DirectiveLocations)
 	p.document.DirectiveDefinitions = append(p.document.DirectiveDefinitions, directiveDefinition)
diff --git a/pkg/asttransform/baseschema.go b/pkg/asttransform/baseschema.go
index cd2c7fce..e5838eb3 100644
--- a/pkg/asttransform/baseschema.go
+++ b/pkg/asttransform/baseschema.go
@@ -178,6 +178,7 @@ type __Directive {
     description: String
     locations: [__DirectiveLocation!]!
     args: [__InputValue!]!
+    isRepeatable: Boolean!
 }
 
 """
diff --git a/pkg/asttransform/fixtures/complete.golden b/pkg/asttransform/fixtures/complete.golden
index ee1d8f43..21d09c0a 100644
--- a/pkg/asttransform/fixtures/complete.golden
+++ b/pkg/asttransform/fixtures/complete.golden
@@ -65,6 +65,7 @@ type __Directive {
     description: String
     locations: [__DirectiveLocation!]!
     args: [__InputValue!]!
+    isRepeatable: Boolean!
     __typename: String!
 }
 
diff --git a/pkg/asttransform/fixtures/custom_query_name.golden b/pkg/asttransform/fixtures/custom_query_name.golden
index 7d6484c3..b96b84cb 100644
--- a/pkg/asttransform/fixtures/custom_query_name.golden
+++ b/pkg/asttransform/fixtures/custom_query_name.golden
@@ -65,6 +65,7 @@ type __Directive {
     description: String
     locations: [__DirectiveLocation!]!
     args: [__InputValue!]!
+    isRepeatable: Boolean!
     __typename: String!
 }
 
diff --git a/pkg/asttransform/fixtures/mutation_only.golden b/pkg/asttransform/fixtures/mutation_only.golden
index c768af91..c68aff9c 100644
--- a/pkg/asttransform/fixtures/mutation_only.golden
+++ b/pkg/asttransform/fixtures/mutation_only.golden
@@ -57,6 +57,7 @@ type __Directive {
     description: String
     locations: [__DirectiveLocation!]!
     args: [__InputValue!]!
+    isRepeatable: Boolean!
     __typename: String!
 }
 
diff --git a/pkg/asttransform/fixtures/schema_missing.golden b/pkg/asttransform/fixtures/schema_missing.golden
index ee1d8f43..21d09c0a 100644
--- a/pkg/asttransform/fixtures/schema_missing.golden
+++ b/pkg/asttransform/fixtures/schema_missing.golden
@@ -65,6 +65,7 @@ type __Directive {
     description: String
     locations: [__DirectiveLocation!]!
     args: [__InputValue!]!
+    isRepeatable: Boolean!
     __typename: String!
 }
 
diff --git a/pkg/asttransform/fixtures/simple.golden b/pkg/asttransform/fixtures/simple.golden
index ee1d8f43..21d09c0a 100644
--- a/pkg/asttransform/fixtures/simple.golden
+++ b/pkg/asttransform/fixtures/simple.golden
@@ -65,6 +65,7 @@ type __Directive {
     description: String
     locations: [__DirectiveLocation!]!
     args: [__InputValue!]!
+    isRepeatable: Boolean!
     __typename: String!
 }
 
diff --git a/pkg/asttransform/fixtures/subscription_only.golden b/pkg/asttransform/fixtures/subscription_only.golden
index 6b15a17a..4f0b1976 100644
--- a/pkg/asttransform/fixtures/subscription_only.golden
+++ b/pkg/asttransform/fixtures/subscription_only.golden
@@ -56,6 +56,7 @@ type __Directive {
     description: String
     locations: [__DirectiveLocation!]!
     args: [__InputValue!]!
+    isRepeatable: Boolean!
     __typename: String!
 }
 
diff --git a/pkg/asttransform/fixtures/subscription_renamed.golden b/pkg/asttransform/fixtures/subscription_renamed.golden
index e200a868..022fb7fe 100644
--- a/pkg/asttransform/fixtures/subscription_renamed.golden
+++ b/pkg/asttransform/fixtures/subscription_renamed.golden
@@ -56,6 +56,7 @@ type __Directive {
     description: String
     locations: [__DirectiveLocation!]!
     args: [__InputValue!]!
+    isRepeatable: Boolean!
     __typename: String!
 }
 
diff --git a/pkg/asttransform/fixtures/with_mutation_subscription.golden b/pkg/asttransform/fixtures/with_mutation_subscription.golden
index 19ff1359..cf6615aa 100644
--- a/pkg/asttransform/fixtures/with_mutation_subscription.golden
+++ b/pkg/asttransform/fixtures/with_mutation_subscription.golden
@@ -76,6 +76,7 @@ type __Directive {
     description: String
     locations: [__DirectiveLocation!]!
     args: [__InputValue!]!
+    isRepeatable: Boolean!
     __typename: String!
 }
 
diff --git a/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden b/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden
index 7a8cf96f..c13884a7 100644
--- a/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden
+++ b/pkg/engine/datasource/introspection_datasource/fixtures/schema_introspection.golden
@@ -186,7 +186,8 @@
           },
           "defaultValue": null
         }
-      ]
+      ],
+      "isRepeatable": false
     },
     {
       "name": "skip",
@@ -211,7 +212,8 @@
           },
           "defaultValue": null
         }
-      ]
+      ],
+      "isRepeatable": false
     },
     {
       "name": "deprecated",
@@ -231,7 +233,8 @@
           },
           "defaultValue": "\"No longer supported\""
         }
-      ]
+      ],
+      "isRepeatable": false
     }
   ]
 }
diff --git a/pkg/graphql/fixtures/introspection_response.golden b/pkg/graphql/fixtures/introspection_response.golden
index f3ddabdd..ebd72349 100644
--- a/pkg/graphql/fixtures/introspection_response.golden
+++ b/pkg/graphql/fixtures/introspection_response.golden
@@ -1,1 +1,1 @@
-{"data":{"__schema":{"queryType":{"name":"Query"},"mutationType":null,"subscriptionType":null,"types":[{"kind":"OBJECT","name":"Query","description":"","fields":[{"name":"hello","description":"","args":[],"type":{"kind":"SCALAR","name":"String","ofType":null},"isDeprecated":false,"deprecationReason":null}],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]},{"kind":"SCALAR","name":"Int","description":"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.","fields":[],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]},{"kind":"SCALAR","name":"Float","description":"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).","fields":[],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]},{"kind":"SCALAR","name":"String","description":"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.","fields":[],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]},{"kind":"SCALAR","name":"Boolean","description":"The 'Boolean' scalar type represents 'true' or 'false' .","fields":[],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]},{"kind":"SCALAR","name":"ID","description":"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID.","fields":[],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]}],"directives":[{"name":"include","description":"Directs the executor to include this field or fragment only when the argument is true.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":"Included when true.","type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"Boolean","ofType":null}},"defaultValue":null}]},{"name":"skip","description":"Directs the executor to skip this field or fragment when the argument is true.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":"Skipped when true.","type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"Boolean","ofType":null}},"defaultValue":null}]},{"name":"deprecated","description":"Marks an element of a GraphQL schema as no longer supported.","locations":["FIELD_DEFINITION","ENUM_VALUE"],"args":[{"name":"reason","description":"Explains why this element was deprecated, usually also including a suggestion\n    for how to access supported similar data. Formatted in\n    [Markdown](https://daringfireball.net/projects/markdown/).","type":{"kind":"SCALAR","name":"String","ofType":null},"defaultValue":"\"No longer supported\""}]}]}}}
+{"data":{"__schema":{"queryType":{"name":"Query"},"mutationType":null,"subscriptionType":null,"types":[{"kind":"OBJECT","name":"Query","description":"","fields":[{"name":"hello","description":"","args":[],"type":{"kind":"SCALAR","name":"String","ofType":null},"isDeprecated":false,"deprecationReason":null}],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]},{"kind":"SCALAR","name":"Int","description":"The 'Int' scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.","fields":[],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]},{"kind":"SCALAR","name":"Float","description":"The 'Float' scalar type represents signed double-precision fractional values as specified by [IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point).","fields":[],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]},{"kind":"SCALAR","name":"String","description":"The 'String' scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.","fields":[],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]},{"kind":"SCALAR","name":"Boolean","description":"The 'Boolean' scalar type represents 'true' or 'false' .","fields":[],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]},{"kind":"SCALAR","name":"ID","description":"The 'ID' scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as '4') or integer (such as 4) input value will be accepted as an ID.","fields":[],"inputFields":[],"interfaces":[],"enumValues":[],"possibleTypes":[]}],"directives":[{"name":"include","description":"Directs the executor to include this field or fragment only when the argument is true.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":"Included when true.","type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"Boolean","ofType":null}},"defaultValue":null}],"isRepeatable":false},{"name":"skip","description":"Directs the executor to skip this field or fragment when the argument is true.","locations":["FIELD","FRAGMENT_SPREAD","INLINE_FRAGMENT"],"args":[{"name":"if","description":"Skipped when true.","type":{"kind":"NON_NULL","name":null,"ofType":{"kind":"SCALAR","name":"Boolean","ofType":null}},"defaultValue":null}],"isRepeatable":false},{"name":"deprecated","description":"Marks an element of a GraphQL schema as no longer supported.","locations":["FIELD_DEFINITION","ENUM_VALUE"],"args":[{"name":"reason","description":"Explains why this element was deprecated, usually also including a suggestion\n    for how to access supported similar data. Formatted in\n    [Markdown](https://daringfireball.net/projects/markdown/).","type":{"kind":"SCALAR","name":"String","ofType":null},"defaultValue":"\"No longer supported\""}],"isRepeatable":false}]}}}
diff --git a/pkg/introspection/fixtures/starwars_introspected.golden b/pkg/introspection/fixtures/starwars_introspected.golden
index 0911b742..b2118a9b 100644
--- a/pkg/introspection/fixtures/starwars_introspected.golden
+++ b/pkg/introspection/fixtures/starwars_introspected.golden
@@ -1279,7 +1279,8 @@
             },
             "defaultValue": null
           }
-        ]
+        ],
+        "isRepeatable": false
       },
       {
         "name": "skip",
@@ -1304,7 +1305,8 @@
             },
             "defaultValue": null
           }
-        ]
+        ],
+        "isRepeatable": false
       },
       {
         "name": "deprecated",
@@ -1324,7 +1326,33 @@
             },
             "defaultValue": "\"No longer supported\""
           }
-        ]
+        ],
+        "isRepeatable": false
+      },
+      {
+        "name": "delegateField",
+        "description": "",
+        "locations": [
+          "OBJECT",
+          "INTERFACE"
+        ],
+        "args": [
+          {
+            "name": "name",
+            "description": "",
+            "type": {
+              "kind": "NON_NULL",
+              "name": null,
+              "ofType": {
+                "kind": "SCALAR",
+                "name": "String",
+                "ofType": null
+              }
+            },
+            "defaultValue": null
+          }
+        ],
+        "isRepeatable": true
       }
     ]
   }
diff --git a/pkg/introspection/generator.go b/pkg/introspection/generator.go
index 43457164..ec0ab43d 100644
--- a/pkg/introspection/generator.go
+++ b/pkg/introspection/generator.go
@@ -263,6 +263,7 @@ func (i *introspectionVisitor) EnterDirectiveDefinition(ref int) {
 	i.currentDirective = NewDirective()
 	i.currentDirective.Name = i.definition.DirectiveDefinitionNameString(ref)
 	i.currentDirective.Description = i.definition.DirectiveDefinitionDescriptionString(ref)
+	i.currentDirective.IsRepeatable = i.definition.DirectiveDefinitions[ref].Repeatable.IsRepeatable
 }
 
 func (i *introspectionVisitor) LeaveDirectiveDefinition(ref int) {
diff --git a/pkg/introspection/introspection.go b/pkg/introspection/introspection.go
index 1b953fbd..58658551 100644
--- a/pkg/introspection/introspection.go
+++ b/pkg/introspection/introspection.go
@@ -140,10 +140,11 @@ type InputValue struct {
 }
 
 type Directive struct {
-	Name        string       `json:"name"`
-	Description string       `json:"description"`
-	Locations   []string     `json:"locations"`
-	Args        []InputValue `json:"args"`
+	Name         string       `json:"name"`
+	Description  string       `json:"description"`
+	Locations    []string     `json:"locations"`
+	Args         []InputValue `json:"args"`
+	IsRepeatable bool         `json:"isRepeatable"`
 }
 
 func NewDirective() Directive {
diff --git a/pkg/lexer/identkeyword/identkeyword.go b/pkg/lexer/identkeyword/identkeyword.go
index 57bdcf28..393c1206 100644
--- a/pkg/lexer/identkeyword/identkeyword.go
+++ b/pkg/lexer/identkeyword/identkeyword.go
@@ -25,6 +25,7 @@ const (
 	INPUT
 	DIRECTIVE
 	EXTEND
+	REPEATABLE
 )
 
 func KeywordFromLiteral(literal []byte) IdentKeyword {
@@ -91,6 +92,9 @@ func KeywordFromLiteral(literal []byte) IdentKeyword {
 		if literal[0] == 'i' && literal[1] == 'm' && literal[2] == 'p' && literal[3] == 'l' && literal[4] == 'e' && literal[5] == 'm' && literal[6] == 'e' && literal[7] == 'n' && literal[8] == 't' && literal[9] == 's' {
 			return IMPLEMENTS
 		}
+		if literal[0] == 'r' && literal[1] == 'e' && literal[2] == 'p' && literal[3] == 'e' && literal[4] == 'a' && literal[5] == 't' && literal[6] == 'a' && literal[7] == 'b' && literal[8] == 'l' && literal[9] == 'e' {
+			return REPEATABLE
+		}
 	case 12:
 		if literal[0] == 's' && literal[1] == 'u' && literal[2] == 'b' && literal[3] == 's' && literal[4] == 'c' && literal[5] == 'r' && literal[6] == 'i' && literal[7] == 'p' && literal[8] == 't' && literal[9] == 'i' && literal[10] == 'o' && literal[11] == 'n' {
 			return SUBSCRIPTION
diff --git a/pkg/lexer/identkeyword/identkeyword_string.go b/pkg/lexer/identkeyword/identkeyword_string.go
index eda4494d..bfc2d13b 100644
--- a/pkg/lexer/identkeyword/identkeyword_string.go
+++ b/pkg/lexer/identkeyword/identkeyword_string.go
@@ -27,11 +27,12 @@ func _() {
 	_ = x[INPUT-16]
 	_ = x[DIRECTIVE-17]
 	_ = x[EXTEND-18]
+	_ = x[REPEATABLE-19]
 }
 
-const _IdentKeyword_name = "UNDEFINEDONTRUEFALSENULLQUERYMUTATIONSUBSCRIPTIONFRAGMENTIMPLEMENTSSCHEMASCALARTYPEINTERFACEUNIONENUMINPUTDIRECTIVEEXTEND"
+const _IdentKeyword_name = "UNDEFINEDONTRUEFALSENULLQUERYMUTATIONSUBSCRIPTIONFRAGMENTIMPLEMENTSSCHEMASCALARTYPEINTERFACEUNIONENUMINPUTDIRECTIVEEXTENDREPEATABLE"
 
-var _IdentKeyword_index = [...]uint8{0, 9, 11, 15, 20, 24, 29, 37, 49, 57, 67, 73, 79, 83, 92, 97, 101, 106, 115, 121}
+var _IdentKeyword_index = [...]uint8{0, 9, 11, 15, 20, 24, 29, 37, 49, 57, 67, 73, 79, 83, 92, 97, 101, 106, 115, 121, 131}
 
 func (i IdentKeyword) String() string {
 	if i < 0 || i >= IdentKeyword(len(_IdentKeyword_index)-1) {


Test patch:
diff --git a/pkg/astparser/parser_test.go b/pkg/astparser/parser_test.go
index f10e8c08..f307a02b 100644
--- a/pkg/astparser/parser_test.go
+++ b/pkg/astparser/parser_test.go
@@ -9,6 +9,8 @@ import (
 	"github.com/jensneuse/graphql-go-tools/pkg/lexer/keyword"
 	"github.com/jensneuse/graphql-go-tools/pkg/lexer/position"
 	"github.com/jensneuse/graphql-go-tools/pkg/operationreport"
+
+	"github.com/stretchr/testify/assert"
 )
 
 func TestParser_Parse(t *testing.T) {
@@ -1388,6 +1390,18 @@ func TestParser_Parse(t *testing.T) {
 					}
 				})
 		})
+		t.Run("repeatable", func(t *testing.T) {
+			run(`directive @example repeatable on FIELD`, parse, false,
+				func(doc *ast.Document, extra interface{}) {
+					example := doc.DirectiveDefinitions[0]
+					if doc.Input.ByteSliceString(example.Name) != "example" {
+						panic("want example")
+					}
+					assert.True(t, example.Repeatable.IsRepeatable)
+					assert.Equal(t, uint32(20), example.Repeatable.Position.CharStart)
+					assert.Equal(t, uint32(30), example.Repeatable.Position.CharEnd)
+				})
+		})
 		t.Run("multiple directive locations", func(t *testing.T) {
 			run(`directive @example on FIELD | SCALAR | SCHEMA`, parse, false,
 				func(doc *ast.Document, extra interface{}) {
diff --git a/pkg/introspection/testdata/out_swapi_introspection_response.json b/pkg/introspection/testdata/out_swapi_introspection_response.json
index 136072ee..85511c25 100755
--- a/pkg/introspection/testdata/out_swapi_introspection_response.json
+++ b/pkg/introspection/testdata/out_swapi_introspection_response.json
@@ -43218,7 +43218,8 @@
             },
             "defaultValue": null
           }
-        ]
+        ],
+        "isRepeatable": false
       },
       {
         "name": "skip",
@@ -43243,7 +43244,8 @@
             },
             "defaultValue": null
           }
-        ]
+        ],
+        "isRepeatable": false
       },
       {
         "name": "deprecated",
@@ -43263,7 +43265,8 @@
             },
             "defaultValue": "\"No longer supported\""
           }
-        ]
+        ],
+        "isRepeatable": false
       }
     ]
   }
diff --git a/pkg/introspection/testdata/starwars.schema.graphql b/pkg/introspection/testdata/starwars.schema.graphql
index e3fae453..1e80760d 100644
--- a/pkg/introspection/testdata/starwars.schema.graphql
+++ b/pkg/introspection/testdata/starwars.schema.graphql
@@ -192,6 +192,9 @@ directive @deprecated(
     """
     reason: String = "No longer supported"
 ) on FIELD_DEFINITION | ENUM_VALUE
+directive @delegateField(
+    name: String!
+) repeatable on OBJECT | INTERFACE
 
 """
 A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.
@@ -205,6 +208,7 @@ type __Directive {
     description: String
     locations: [__DirectiveLocation!]!
     args: [__InputValue!]!
+    isRepeatable: Boolean!
 }
 
 """