Skip to content

Commit 5c581bb

Browse files
committed
feat: add Description support for VariableDefinition (September 2025 spec)
Implements Description? on VariableDefinition per the September 2025 GraphQL spec (graphql-spec PR #1170), following the same pattern established in PR #1331 for OperationDefinition and FragmentDefinition. - Parser: handle optional string/block-string description before $Variable - Printer: output variable descriptions when present - JSON Schema Builder: propagate variable descriptions to JSON Schema property description fields, with fallback to argument descriptions from the schema definition when no variable description is provided Resolves ENG-9301
1 parent 0507f24 commit 5c581bb

8 files changed

Lines changed: 447 additions & 2 deletions

File tree

v2/pkg/ast/ast_variable_definition.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ type VariableDefinitionList struct {
1616

1717
// VariableDefinition
1818
// example:
19-
// $devicePicSize: Int = 100 @small
19+
// "The device picture size" $devicePicSize: Int = 100 @small
2020
type VariableDefinition struct {
21+
Description Description // optional, describes the variable (September 2025 spec)
2122
VariableValue Value // $ Name
2223
Colon position.Position // :
2324
Type int // e.g. String
@@ -93,3 +94,14 @@ func (d *Document) VariablePathByArgumentRefAndArgumentPath(argumentRef int, arg
9394
// The variable path should be the variable name, e.g., "a", and then the 2nd element from the path onwards
9495
return append([]string{string(variableNameBytes)}, argumentPath[1:]...), nil
9596
}
97+
98+
func (d *Document) VariableDefinitionDescriptionBytes(ref int) ByteSlice {
99+
if !d.VariableDefinitions[ref].Description.IsDefined {
100+
return nil
101+
}
102+
return d.Input.ByteSlice(d.VariableDefinitions[ref].Description.Content)
103+
}
104+
105+
func (d *Document) VariableDefinitionDescriptionString(ref int) string {
106+
return unsafebytes.BytesToString(d.VariableDefinitionDescriptionBytes(ref))
107+
}

v2/pkg/astparser/parser.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1557,6 +1557,17 @@ func (p *Parser) parseVariableDefinitionList() (list ast.VariableDefinitionList)
15571557
case keyword.RPAREN:
15581558
list.RPAREN = p.read().TextPosition
15591559
return
1560+
case keyword.STRING, keyword.BLOCKSTRING:
1561+
// Description before variable definition (September 2025 spec)
1562+
description := p.parseDescription()
1563+
if cap(list.Refs) == 0 {
1564+
list.Refs = p.document.Refs[p.document.NextRefIndex()][:0]
1565+
}
1566+
ref := p.parseVariableDefinitionWithDescription(&description)
1567+
if cap(list.Refs) == 0 {
1568+
list.Refs = p.document.Refs[p.document.NextRefIndex()][:0]
1569+
}
1570+
list.Refs = append(list.Refs, ref)
15601571
case keyword.DOLLAR:
15611572
if cap(list.Refs) == 0 {
15621573
list.Refs = p.document.Refs[p.document.NextRefIndex()][:0]
@@ -1567,7 +1578,7 @@ func (p *Parser) parseVariableDefinitionList() (list ast.VariableDefinitionList)
15671578
}
15681579
list.Refs = append(list.Refs, ref)
15691580
default:
1570-
p.errUnexpectedToken(p.read(), keyword.RPAREN, keyword.DOLLAR)
1581+
p.errUnexpectedToken(p.read(), keyword.RPAREN, keyword.DOLLAR, keyword.STRING, keyword.BLOCKSTRING)
15711582
return
15721583
}
15731584

@@ -1578,9 +1589,17 @@ func (p *Parser) parseVariableDefinitionList() (list ast.VariableDefinitionList)
15781589
}
15791590

15801591
func (p *Parser) parseVariableDefinition() int {
1592+
return p.parseVariableDefinitionWithDescription(nil)
1593+
}
1594+
1595+
func (p *Parser) parseVariableDefinitionWithDescription(description *ast.Description) int {
15811596

15821597
var variableDefinition ast.VariableDefinition
15831598

1599+
if description != nil {
1600+
variableDefinition.Description = *description
1601+
}
1602+
15841603
variableDefinition.VariableValue.Kind = ast.ValueKindVariable
15851604
variableDefinition.VariableValue.Ref, variableDefinition.VariableValue.Position = p.parseVariableValue()
15861605

v2/pkg/astparser/parser_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2986,6 +2986,41 @@ func TestParseFragmentsWithDescriptions(t *testing.T) {
29862986
assert.False(t, userWithoutDescFrag.Description.IsDefined, "UserWithoutDescription should not have description")
29872987
}
29882988

2989+
func TestParseVariablesWithDescriptions(t *testing.T) {
2990+
operationsFile, err := os.ReadFile("./testdata/variable_with_description.graphql")
2991+
if err != nil {
2992+
t.Fatal(err)
2993+
}
2994+
2995+
doc, report := ParseGraphqlDocumentBytes(operationsFile)
2996+
if report.HasErrors() {
2997+
t.Fatal(report)
2998+
}
2999+
3000+
// Verify we parsed the operation
3001+
require.Len(t, doc.OperationDefinitions, 1)
3002+
3003+
opDef := doc.OperationDefinitions[0]
3004+
require.True(t, opDef.HasVariableDefinitions)
3005+
require.Len(t, opDef.VariableDefinitions.Refs, 3)
3006+
3007+
// First variable: $id with single-line description
3008+
var1 := doc.VariableDefinitions[opDef.VariableDefinitions.Refs[0]]
3009+
assert.True(t, var1.Description.IsDefined, "$id should have description")
3010+
assert.False(t, var1.Description.IsBlockString, "$id description should be single-line")
3011+
assert.Equal(t, "The unique employee identifier", doc.Input.ByteSliceString(var1.Description.Content))
3012+
3013+
// Second variable: $department with block string description
3014+
var2 := doc.VariableDefinitions[opDef.VariableDefinitions.Refs[1]]
3015+
assert.True(t, var2.Description.IsDefined, "$department should have description")
3016+
assert.True(t, var2.Description.IsBlockString, "$department description should be block string")
3017+
assert.Contains(t, doc.Input.ByteSliceString(var2.Description.Content), "The department to filter by")
3018+
3019+
// Third variable: $limit without description (backward compatibility)
3020+
var3 := doc.VariableDefinitions[opDef.VariableDefinitions.Refs[2]]
3021+
assert.False(t, var3.Description.IsDefined, "$limit should not have description")
3022+
}
3023+
29893024
func BenchmarkParseStarwars(b *testing.B) {
29903025

29913026
inputFileName := "./testdata/starwars.schema.graphql"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""
2+
Get an employee by their ID
3+
"""
4+
query FindEmployee(
5+
"The unique employee identifier"
6+
$id: ID!
7+
"""
8+
The department to filter by.
9+
Optional parameter for narrowing results.
10+
"""
11+
$department: String
12+
$limit: Int = 10
13+
) {
14+
employee(id: $id) {
15+
id
16+
name
17+
}
18+
}

v2/pkg/astprinter/astprinter.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,11 @@ func (p *printVisitor) EnterVariableDefinition(ref int) {
231231
p.write(literal.LPAREN)
232232
}
233233

234+
if p.document.VariableDefinitions[ref].Description.IsDefined {
235+
p.must(p.document.PrintDescription(p.document.VariableDefinitions[ref].Description, nil, 0, p.out))
236+
p.write(literal.SPACE)
237+
}
238+
234239
p.must(p.document.PrintValue(p.document.VariableDefinitions[ref].VariableValue, p.out))
235240
p.write(literal.COLON)
236241
p.write(literal.SPACE)

v2/pkg/astprinter/astprinter_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,36 @@ User fields fragment
787787
"""
788788
fragment UserFields on User {id name}`)
789789
})
790+
t.Run("variable with single-line description", func(t *testing.T) {
791+
run(t, `query GetUser("The user ID" $id: ID!) {
792+
user(id: $id) {
793+
id
794+
}
795+
}`, `query GetUser("The user ID" $id: ID!){user(id: $id){id}}`)
796+
})
797+
t.Run("variable with block string description", func(t *testing.T) {
798+
run(t, `query GetUser("""The unique identifier""" $id: ID!) {
799+
user(id: $id) {
800+
id
801+
}
802+
}`, `query GetUser("""
803+
The unique identifier
804+
""" $id: ID!){user(id: $id){id}}`)
805+
})
806+
t.Run("multiple variables with mixed descriptions", func(t *testing.T) {
807+
run(t, `query Search("The search query" $query: String!, $limit: Int) {
808+
search(query: $query, limit: $limit) {
809+
id
810+
}
811+
}`, `query Search("The search query" $query: String!, $limit: Int){search(query: $query, limit: $limit){id}}`)
812+
})
813+
t.Run("variable without description unchanged", func(t *testing.T) {
814+
run(t, `query GetUser($id: ID!) {
815+
user(id: $id) {
816+
id
817+
}
818+
}`, `query GetUser($id: ID!){user(id: $id){id}}`)
819+
})
790820
}
791821

792822
func TestPrintArgumentWithBeforeAfterValue(t *testing.T) {

v2/pkg/engine/jsonschema/variables_schema.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@ func (v *VariablesSchemaBuilder) EnterVariableDefinition(ref int) {
131131
v.schema.Required = append(v.schema.Required, varName)
132132
}
133133

134+
// Set variable description: use the variable's own description if present (AC2),
135+
// otherwise fall back to the field argument description from the schema (AC3)
136+
if v.operationDocument.VariableDefinitions[ref].Description.IsDefined {
137+
varSchema.Description = v.operationDocument.VariableDefinitionDescriptionString(ref)
138+
} else {
139+
// Fall back to argument description from schema definition
140+
if desc := v.findArgumentDescriptionForVariable(varName); desc != "" {
141+
varSchema.Description = desc
142+
}
143+
}
144+
134145
// Set default value if exists
135146
if v.operationDocument.VariableDefinitionHasDefaultValue(ref) {
136147
defaultValue := v.operationDocument.VariableDefinitionDefaultValue(ref)
@@ -150,6 +161,85 @@ func (v *VariablesSchemaBuilder) EnterVariableDefinition(ref int) {
150161
v.schema.Properties[varName] = varSchema
151162
}
152163

164+
// findArgumentDescriptionForVariable looks up the argument description from the schema definition
165+
// for a variable by matching it to field arguments in the operation's root selection set.
166+
func (v *VariablesSchemaBuilder) findArgumentDescriptionForVariable(varName string) string {
167+
if len(v.operationDocument.OperationDefinitions) == 0 {
168+
return ""
169+
}
170+
171+
operationDef := v.operationDocument.OperationDefinitions[0]
172+
if !operationDef.HasSelections {
173+
return ""
174+
}
175+
176+
// Determine root type name
177+
var rootTypeName string
178+
switch operationDef.OperationType {
179+
case ast.OperationTypeQuery:
180+
rootTypeName = "Query"
181+
case ast.OperationTypeMutation:
182+
rootTypeName = "Mutation"
183+
case ast.OperationTypeSubscription:
184+
rootTypeName = "Subscription"
185+
default:
186+
return ""
187+
}
188+
189+
rootType, exists := v.definitionDocument.Index.FirstNodeByNameStr(rootTypeName)
190+
if !exists || rootType.Kind != ast.NodeKindObjectTypeDefinition {
191+
return ""
192+
}
193+
194+
// Iterate through root fields in the operation's selection set
195+
selectionSetRef := operationDef.SelectionSet
196+
for _, selectionRef := range v.operationDocument.SelectionSets[selectionSetRef].SelectionRefs {
197+
selection := v.operationDocument.Selections[selectionRef]
198+
if selection.Kind != ast.SelectionKindField {
199+
continue
200+
}
201+
202+
fieldRef := selection.Ref
203+
// Check if this field has arguments referencing our variable
204+
if !v.operationDocument.FieldHasArguments(fieldRef) {
205+
continue
206+
}
207+
208+
for _, argRef := range v.operationDocument.Fields[fieldRef].Arguments.Refs {
209+
argValue := v.operationDocument.ArgumentValue(argRef)
210+
if argValue.Kind != ast.ValueKindVariable {
211+
continue
212+
}
213+
argVarName := v.operationDocument.VariableValueNameString(argValue.Ref)
214+
if argVarName != varName {
215+
continue
216+
}
217+
218+
// Found the argument that references this variable.
219+
// Look up the corresponding argument definition in the schema.
220+
argName := v.operationDocument.ArgumentNameString(argRef)
221+
fieldName := v.operationDocument.FieldNameString(fieldRef)
222+
223+
// Find this field in the root type definition
224+
for _, fieldDefRef := range v.definitionDocument.ObjectTypeDefinitions[rootType.Ref].FieldsDefinition.Refs {
225+
if v.definitionDocument.FieldDefinitionNameString(fieldDefRef) != fieldName {
226+
continue
227+
}
228+
// Find the argument definition
229+
for _, argDefRef := range v.definitionDocument.FieldDefinitionArgumentsDefinitions(fieldDefRef) {
230+
if v.definitionDocument.InputValueDefinitionNameString(argDefRef) == argName {
231+
if v.definitionDocument.InputValueDefinitions[argDefRef].Description.IsDefined {
232+
return v.definitionDocument.InputValueDefinitionDescriptionString(argDefRef)
233+
}
234+
}
235+
}
236+
}
237+
}
238+
}
239+
240+
return ""
241+
}
242+
153243
// GetSchema returns the built schema
154244
func (v *VariablesSchemaBuilder) GetSchema() *JsonSchema {
155245
// If we have required fields, the root schema cannot be nullable

0 commit comments

Comments
 (0)