From 617986d87ebec9207a066b74c2ca9d6704d14644 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Wed, 20 May 2026 18:06:51 +1000 Subject: [PATCH] Add executable description support --- src/main/antlr/GraphqlOperation.g4 | 6 +-- .../java/graphql/language/AstPrinter.java | 47 ++++++++++++++---- .../graphql/language/FragmentDefinition.java | 15 ++++-- .../graphql/language/OperationDefinition.java | 14 +++++- .../graphql/language/VariableDefinition.java | 18 +++++-- .../parser/GraphqlAntlrToLanguage.java | 3 ++ .../graphql/ParseAndValidateTest.groovy | 27 +++++++++++ .../graphql/language/AstPrinterTest.groovy | 39 +++++++++++++++ .../groovy/graphql/parser/ParserTest.groovy | 48 +++++++++++++++++++ 9 files changed, 195 insertions(+), 22 deletions(-) diff --git a/src/main/antlr/GraphqlOperation.g4 b/src/main/antlr/GraphqlOperation.g4 index e5c862940a..fe4a9ad745 100644 --- a/src/main/antlr/GraphqlOperation.g4 +++ b/src/main/antlr/GraphqlOperation.g4 @@ -3,11 +3,11 @@ import GraphqlCommon; operationDefinition: selectionSet | -operationType name? variableDefinitions? directives? selectionSet; +description? operationType name? variableDefinitions? directives? selectionSet; variableDefinitions : '(' variableDefinition+ ')'; -variableDefinition : variable ':' type defaultValue? directives?; +variableDefinition : description? variable ':' type defaultValue? directives?; selectionSet : '{' selection+ '}'; @@ -27,7 +27,7 @@ fragmentSpread : '...' fragmentName directives?; inlineFragment : '...' typeCondition? directives? selectionSet; -fragmentDefinition : FRAGMENT fragmentName typeCondition directives? selectionSet; +fragmentDefinition : description? FRAGMENT fragmentName typeCondition directives? selectionSet; typeCondition : ON_KEYWORD typeName; diff --git a/src/main/java/graphql/language/AstPrinter.java b/src/main/java/graphql/language/AstPrinter.java index 4fb52737f3..6266f71e5f 100644 --- a/src/main/java/graphql/language/AstPrinter.java +++ b/src/main/java/graphql/language/AstPrinter.java @@ -249,8 +249,13 @@ private static boolean hasDescription(Node node) { return false; } + private static boolean hasDescription(List> nodes) { + return nodes.stream().anyMatch(AstPrinter::hasDescription); + } + private NodePrinter fragmentDefinition() { return (out, node) -> { + description(out, node); out.append("fragment "); out.append(node.getName()); out.append(" on "); @@ -367,24 +372,17 @@ private NodePrinter operationDefinition() { String name = node.getName(); // Anonymous queries with no directives or variable definitions can use // the query short form. - if (isEmpty(name) && isEmpty(node.getDirectives()) && isEmpty(node.getVariableDefinitions()) - && node.getOperation() == OperationDefinition.Operation.QUERY) { + if (canUseQueryShortForm(node)) { node(out, node.getSelectionSet()); } else { + description(out, node); OperationDefinition.Operation op = node.getOperation(); out.append(op.toString().toLowerCase()); if (!isEmpty(name)) { out.append(' '); out.append(name); } - if (!isEmpty(node.getVariableDefinitions())) { - if (isEmpty(name)) { - out.append(' '); - } - out.append('('); - join(out, node.getVariableDefinitions(), argSep); - out.append(')'); - } + variableDefinitions(out, node, argSep); if (!isEmpty(node.getDirectives())) { out.append(' '); directives(out, node.getDirectives()); @@ -397,6 +395,34 @@ private NodePrinter operationDefinition() { }; } + private boolean canUseQueryShortForm(OperationDefinition node) { + return (compactMode || !hasDescription(node)) + && isEmpty(node.getName()) + && isEmpty(node.getDirectives()) + && isEmpty(node.getVariableDefinitions()) + && node.getOperation() == OperationDefinition.Operation.QUERY; + } + + private void variableDefinitions(StringBuilder out, OperationDefinition node, String argSep) { + if (isEmpty(node.getVariableDefinitions())) { + return; + } + if (isEmpty(node.getName())) { + out.append(' '); + } + if (!compactMode && hasDescription(node.getVariableDefinitions())) { + int offset = out.length(); + out.append("(\n"); + join(out, node.getVariableDefinitions(), "\n"); + indent(out, offset); + out.append("\n)"); + return; + } + out.append('('); + join(out, node.getVariableDefinitions(), argSep); + out.append(')'); + } + private NodePrinter operationTypeDefinition() { String nameTypeSep = compactMode ? ":" : ": "; return (out, node) -> { @@ -546,6 +572,7 @@ private NodePrinter variableDefinition() { String nameTypeSep = compactMode ? ":" : ": "; String defaultValueEquals = compactMode ? "=" : " = "; return (out, node) -> { + description(out, node); out.append('$'); out.append(node.getName()); out.append(nameTypeSep); diff --git a/src/main/java/graphql/language/FragmentDefinition.java b/src/main/java/graphql/language/FragmentDefinition.java index 79fcf609ef..00b51ae155 100644 --- a/src/main/java/graphql/language/FragmentDefinition.java +++ b/src/main/java/graphql/language/FragmentDefinition.java @@ -27,7 +27,7 @@ */ @PublicApi @NullMarked -public class FragmentDefinition extends AbstractNode implements Definition, SelectionSetContainer, DirectivesContainer, NamedNode { +public class FragmentDefinition extends AbstractDescribedNode implements Definition, SelectionSetContainer, DirectivesContainer, NamedNode { private final String name; private final TypeName typeCondition; @@ -43,11 +43,12 @@ protected FragmentDefinition(String name, TypeName typeCondition, List directives, SelectionSet selectionSet, + @Nullable Description description, @Nullable SourceLocation sourceLocation, List comments, IgnoredChars ignoredChars, Map additionalData) { - super(sourceLocation, comments, ignoredChars, additionalData); + super(sourceLocation, comments, ignoredChars, additionalData, description); this.name = name; this.typeCondition = typeCondition; this.directives = NodeUtil.DirectivesHolder.of(directives); @@ -136,6 +137,7 @@ public FragmentDefinition deepCopy() { assertNotNull(deepCopy(typeCondition)), assertNotNull(deepCopy(directives.getDirectives())), assertNotNull(deepCopy(selectionSet)), + description, getSourceLocation(), getComments(), getIgnoredChars(), @@ -174,6 +176,7 @@ public static final class Builder implements NodeDirectivesBuilder { private String name; private TypeName typeCondition; + private Description description; private ImmutableList directives = emptyList(); private SelectionSet selectionSet; private IgnoredChars ignoredChars = IgnoredChars.EMPTY; @@ -187,6 +190,7 @@ private Builder(FragmentDefinition existing) { this.comments = ImmutableList.copyOf(existing.getComments()); this.name = existing.getName(); this.typeCondition = existing.getTypeCondition(); + this.description = existing.getDescription(); this.directives = ImmutableList.copyOf(existing.getDirectives()); this.selectionSet = existing.getSelectionSet(); this.ignoredChars = existing.getIgnoredChars(); @@ -214,6 +218,11 @@ public Builder typeCondition(TypeName typeCondition) { return this; } + public Builder description(Description description) { + this.description = description; + return this; + } + @Override public Builder directives(List directives) { this.directives = ImmutableList.copyOf(directives); @@ -247,7 +256,7 @@ public Builder additionalData(String key, String value) { public FragmentDefinition build() { - return new FragmentDefinition(assertNotNull(name), assertNotNull(typeCondition), directives, assertNotNull(selectionSet), sourceLocation, comments, ignoredChars, additionalData); + return new FragmentDefinition(assertNotNull(name), assertNotNull(typeCondition), directives, assertNotNull(selectionSet), description, sourceLocation, comments, ignoredChars, additionalData); } } } diff --git a/src/main/java/graphql/language/OperationDefinition.java b/src/main/java/graphql/language/OperationDefinition.java index 845dd4e146..4e83629ae1 100644 --- a/src/main/java/graphql/language/OperationDefinition.java +++ b/src/main/java/graphql/language/OperationDefinition.java @@ -26,7 +26,7 @@ @PublicApi @NullMarked -public class OperationDefinition extends AbstractNode implements Definition, SelectionSetContainer, DirectivesContainer, NamedNode { +public class OperationDefinition extends AbstractDescribedNode implements Definition, SelectionSetContainer, DirectivesContainer, NamedNode { public enum Operation { QUERY, MUTATION, SUBSCRIPTION @@ -49,11 +49,12 @@ protected OperationDefinition(@Nullable String name, List variableDefinitions, List directives, SelectionSet selectionSet, + @Nullable Description description, @Nullable SourceLocation sourceLocation, List comments, IgnoredChars ignoredChars, Map additionalData) { - super(sourceLocation, comments, ignoredChars, additionalData); + super(sourceLocation, comments, ignoredChars, additionalData, description); this.name = name; this.operation = operation; this.variableDefinitions = ImmutableList.copyOf(variableDefinitions); @@ -147,6 +148,7 @@ public OperationDefinition deepCopy() { assertNotNull(deepCopy(variableDefinitions), "variableDefinitions deepCopy should not return null"), assertNotNull(deepCopy(directives.getDirectives()), "directives deepCopy should not return null"), assertNotNull(deepCopy(selectionSet), "selectionSet deepCopy should not return null"), + description, getSourceLocation(), getComments(), getIgnoredChars(), @@ -185,6 +187,7 @@ public static final class Builder implements NodeDirectivesBuilder { private ImmutableList comments = emptyList(); private String name; private Operation operation = Operation.QUERY; + private Description description; private ImmutableList variableDefinitions = emptyList(); private ImmutableList directives = emptyList(); private SelectionSet selectionSet; @@ -199,6 +202,7 @@ private Builder(OperationDefinition existing) { this.comments = ImmutableList.copyOf(existing.getComments()); this.name = existing.getName(); this.operation = existing.getOperation(); + this.description = existing.getDescription(); this.variableDefinitions = ImmutableList.copyOf(existing.getVariableDefinitions()); this.directives = ImmutableList.copyOf(existing.getDirectives()); this.selectionSet = existing.getSelectionSet(); @@ -227,6 +231,11 @@ public Builder operation(Operation operation) { return this; } + public Builder description(Description description) { + this.description = description; + return this; + } + public Builder variableDefinitions(List variableDefinitions) { this.variableDefinitions = ImmutableList.copyOf(variableDefinitions); return this; @@ -275,6 +284,7 @@ public OperationDefinition build() { variableDefinitions, directives, selectionSet, + description, sourceLocation, comments, ignoredChars, diff --git a/src/main/java/graphql/language/VariableDefinition.java b/src/main/java/graphql/language/VariableDefinition.java index c119222d47..ce913e17d1 100644 --- a/src/main/java/graphql/language/VariableDefinition.java +++ b/src/main/java/graphql/language/VariableDefinition.java @@ -21,7 +21,7 @@ import static graphql.language.NodeChildrenContainer.newNodeChildrenContainer; @PublicApi -public class VariableDefinition extends AbstractNode implements DirectivesContainer, NamedNode { +public class VariableDefinition extends AbstractDescribedNode implements DirectivesContainer, NamedNode { private final String name; private final Type type; @@ -37,11 +37,12 @@ protected VariableDefinition(String name, Type type, Value defaultValue, List directives, + Description description, SourceLocation sourceLocation, List comments, IgnoredChars ignoredChars, Map additionalData) { - super(sourceLocation, comments, ignoredChars, additionalData); + super(sourceLocation, comments, ignoredChars, additionalData, description); this.name = name; this.type = type; this.defaultValue = defaultValue; @@ -58,7 +59,7 @@ protected VariableDefinition(String name, public VariableDefinition(String name, Type type, Value defaultValue) { - this(name, type, defaultValue, emptyList(), null, emptyList(), IgnoredChars.EMPTY, emptyMap()); + this(name, type, defaultValue, emptyList(), null, null, emptyList(), IgnoredChars.EMPTY, emptyMap()); } /** @@ -69,7 +70,7 @@ public VariableDefinition(String name, */ public VariableDefinition(String name, Type type) { - this(name, type, null, emptyList(), null, emptyList(), IgnoredChars.EMPTY, emptyMap()); + this(name, type, null, emptyList(), null, null, emptyList(), IgnoredChars.EMPTY, emptyMap()); } public Value getDefaultValue() { @@ -154,6 +155,7 @@ public VariableDefinition deepCopy() { deepCopy(type), deepCopy(defaultValue), deepCopy(directives.getDirectives()), + description, getSourceLocation(), getComments(), getIgnoredChars(), @@ -204,6 +206,7 @@ public static final class Builder implements NodeDirectivesBuilder { private ImmutableList comments = emptyList(); private Type type; private Value defaultValue; + private Description description; private ImmutableList directives = emptyList(); private IgnoredChars ignoredChars = IgnoredChars.EMPTY; private Map additionalData = new LinkedHashMap<>(); @@ -217,6 +220,7 @@ private Builder(VariableDefinition existing) { this.name = existing.getName(); this.type = existing.getType(); this.defaultValue = existing.getDefaultValue(); + this.description = existing.getDescription(); this.directives = ImmutableList.copyOf(existing.getDirectives()); this.ignoredChars = existing.getIgnoredChars(); this.additionalData = new LinkedHashMap<>(existing.getAdditionalData()); @@ -247,6 +251,11 @@ public Builder defaultValue(Value defaultValue) { return this; } + public Builder description(Description description) { + this.description = description; + return this; + } + @Override public Builder directives(List directives) { this.directives = ImmutableList.copyOf(directives); @@ -279,6 +288,7 @@ public VariableDefinition build() { type, defaultValue, directives, + description, sourceLocation, comments, ignoredChars, diff --git a/src/main/java/graphql/parser/GraphqlAntlrToLanguage.java b/src/main/java/graphql/parser/GraphqlAntlrToLanguage.java index 125e184d19..f8c446ed57 100644 --- a/src/main/java/graphql/parser/GraphqlAntlrToLanguage.java +++ b/src/main/java/graphql/parser/GraphqlAntlrToLanguage.java @@ -142,6 +142,7 @@ protected OperationDefinition createOperationDefinition(GraphqlParser.OperationD if (ctx.name() != null) { operationDefinition.name(ctx.name().getText()); } + operationDefinition.description(newDescription(ctx.description())); operationDefinition.variableDefinitions(createVariableDefinitions(ctx.variableDefinitions())); operationDefinition.selectionSet(createSelectionSet(ctx.selectionSet())); operationDefinition.directives(createDirectives(ctx.directives())); @@ -178,6 +179,7 @@ protected List createVariableDefinitions(GraphqlParser.Varia protected VariableDefinition createVariableDefinition(GraphqlParser.VariableDefinitionContext ctx) { VariableDefinition.Builder variableDefinition = VariableDefinition.newVariableDefinition(); addCommonData(variableDefinition, ctx); + variableDefinition.description(newDescription(ctx.description())); variableDefinition.name(ctx.variable().name().getText()); if (ctx.defaultValue() != null) { Value value = createValue(ctx.defaultValue().value()); @@ -192,6 +194,7 @@ protected VariableDefinition createVariableDefinition(GraphqlParser.VariableDefi protected FragmentDefinition createFragmentDefinition(GraphqlParser.FragmentDefinitionContext ctx) { FragmentDefinition.Builder fragmentDefinition = FragmentDefinition.newFragmentDefinition(); addCommonData(fragmentDefinition, ctx); + fragmentDefinition.description(newDescription(ctx.description())); fragmentDefinition.name(ctx.fragmentName().getText()); fragmentDefinition.typeCondition(TypeName.newTypeName().name(ctx.typeCondition().typeName().getText()).build()); fragmentDefinition.directives(createDirectives(ctx.directives())); diff --git a/src/test/groovy/graphql/ParseAndValidateTest.groovy b/src/test/groovy/graphql/ParseAndValidateTest.groovy index 5b9c409559..52864a4de8 100644 --- a/src/test/groovy/graphql/ParseAndValidateTest.groovy +++ b/src/test/groovy/graphql/ParseAndValidateTest.groovy @@ -59,6 +59,33 @@ class ParseAndValidateTest extends Specification { errors.isEmpty() } + def "executable descriptions do not affect validation"() { + def input = ExecutionInput.newExecutionInput(''' + "Fetches a hero" + query HeroName( + "The target episode" + $episode: Episode = JEDI + ) { + hero(episode: $episode) { + ...heroFields + } + } + + "Reusable hero fields" + fragment heroFields on Character { + name + } + ''').build() + def result = ParseAndValidate.parse(input) + + when: + def errors = ParseAndValidate.validate(StarWarsSchema.starWarsSchema, result.getDocument(), input.getLocale()) + + then: + !result.isFailure() + errors.isEmpty() + } + def "will validate documents with actual problems"() { def input = ExecutionInput.newExecutionInput("query { hero }").variables([var1: 1]).build() diff --git a/src/test/groovy/graphql/language/AstPrinterTest.groovy b/src/test/groovy/graphql/language/AstPrinterTest.groovy index 84c2de667a..c3db51364c 100644 --- a/src/test/groovy/graphql/language/AstPrinterTest.groovy +++ b/src/test/groovy/graphql/language/AstPrinterTest.groovy @@ -406,6 +406,45 @@ query HeroNameAndFriends($episode: Episode = "JEDI") { ''' } + def "ast printing of executable descriptions"() { + def query = ''' +"Fetches a hero" +query getHero( + "The hero id" + $id: ID! +) { + hero(id: $id) { + ...heroFields + } +} + +"Reusable hero fields" +fragment heroFields on Hero { + name +} +''' + def document = parse(query) + String output = printAst(document) + + expect: + output == '''"Fetches a hero" +query getHero( + "The hero id" + $id: ID! +) { + hero(id: $id) { + ...heroFields + } +} + +"Reusable hero fields" +fragment heroFields on Hero { + name +} +''' + isParseableAst(output) + } + //------------------------------------------------- def "ast printing of null"() { def query = ''' diff --git a/src/test/groovy/graphql/parser/ParserTest.groovy b/src/test/groovy/graphql/parser/ParserTest.groovy index 94724742d4..7ffdb9c294 100644 --- a/src/test/groovy/graphql/parser/ParserTest.groovy +++ b/src/test/groovy/graphql/parser/ParserTest.groovy @@ -5,6 +5,7 @@ import graphql.language.ArrayValue import graphql.language.AstComparator import graphql.language.AstPrinter import graphql.language.BooleanValue +import graphql.language.DescribedNode import graphql.language.Description import graphql.language.Directive import graphql.language.DirectiveDefinition @@ -109,6 +110,53 @@ class ParserTest extends Specification { isEqual(document, expectedResult.build()) } + def "parse executable descriptions"() { + given: + def input = ''' + "Fetches a hero" + query getHero( + "The hero id" + $id: ID! + ) { + hero(id: $id) { + ...heroFields + } + } + + "Reusable hero fields" + fragment heroFields on Hero { + name + } + ''' + + when: + Document document = new Parser().parseDocument(input) + OperationDefinition operationDefinition = document.definitions[0] as OperationDefinition + VariableDefinition variableDefinition = operationDefinition.variableDefinitions[0] + FragmentDefinition fragmentDefinition = document.definitions[1] as FragmentDefinition + + then: + (operationDefinition as DescribedNode).description.content == "Fetches a hero" + (variableDefinition as DescribedNode).description.content == "The hero id" + (fragmentDefinition as DescribedNode).description.content == "Reusable hero fields" + } + + def "description is not allowed on query shorthand"() { + given: + def input = ''' + "Shorthand descriptions are not part of the executable grammar" + { + hero + } + ''' + + when: + new Parser().parseDocument(input) + + then: + thrown(InvalidSyntaxException) + } + def "parse mutation"() { given: def input = 'mutation setName { setName(name: "Homer") { newName } }'