diff --git a/src/main/java/graphql/Directives.java b/src/main/java/graphql/Directives.java index f33b43d84..7dbc9dea6 100644 --- a/src/main/java/graphql/Directives.java +++ b/src/main/java/graphql/Directives.java @@ -1,21 +1,27 @@ package graphql; +import graphql.language.ArrayValue; import graphql.language.BooleanValue; import graphql.language.Description; import graphql.language.DirectiveDefinition; +import graphql.language.IntValue; +import graphql.language.ListType; import graphql.language.StringValue; import graphql.schema.GraphQLDirective; import org.jspecify.annotations.NullMarked; +import java.math.BigInteger; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import static graphql.Scalars.GraphQLBoolean; +import static graphql.Scalars.GraphQLInt; import static graphql.Scalars.GraphQLString; import static graphql.introspection.Introspection.DirectiveLocation.ARGUMENT_DEFINITION; import static graphql.introspection.Introspection.DirectiveLocation.DIRECTIVE_DEFINITION; @@ -35,6 +41,7 @@ import static graphql.language.NonNullType.newNonNullType; import static graphql.language.TypeName.newTypeName; import static graphql.schema.GraphQLArgument.newArgument; +import static graphql.schema.GraphQLList.list; import static graphql.schema.GraphQLNonNull.nonNull; /** @@ -51,6 +58,8 @@ public class Directives { private static final String ONE_OF = "oneOf"; private static final String DEFER = "defer"; private static final String EXPERIMENTAL_DISABLE_ERROR_PROPAGATION = "experimental_disableErrorPropagation"; + private static final String SEMANTIC_NON_NULL = "semanticNonNull"; + private static final String INT = "Int"; public static final DirectiveDefinition DEPRECATED_DIRECTIVE_DEFINITION; public static final DirectiveDefinition INCLUDE_DIRECTIVE_DEFINITION; @@ -62,6 +71,8 @@ public class Directives { public static final DirectiveDefinition DEFER_DIRECTIVE_DEFINITION; @ExperimentalApi public static final DirectiveDefinition EXPERIMENTAL_DISABLE_ERROR_PROPAGATION_DIRECTIVE_DEFINITION; + @ExperimentalApi + public static final DirectiveDefinition SEMANTIC_NON_NULL_DIRECTIVE_DEFINITION; public static final String BOOLEAN = "Boolean"; public static final String STRING = "String"; @@ -157,6 +168,21 @@ public class Directives { .directiveLocation(newDirectiveLocation().name(SUBSCRIPTION.name()).build()) .description(createDescription("This directive allows returning null in non-null positions that have an associated error")) .build(); + + SEMANTIC_NON_NULL_DIRECTIVE_DEFINITION = DirectiveDefinition.newDirectiveDefinition() + .name(SEMANTIC_NON_NULL) + .directiveLocation(newDirectiveLocation().name(FIELD_DEFINITION.name()).build()) + .description(createDescription("Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.")) + .inputValueDefinition( + newInputValueDefinition() + .name("levels") + .description(createDescription("The list dimensions that are semantically non null, with 0 being the outermost position.")) + .type(newNonNullType(new ListType(newNonNullType(newTypeName().name(INT).build()).build())).build()) + .defaultValue(ArrayValue.newArrayValue() + .value(IntValue.newIntValue(BigInteger.ZERO).build()) + .build()) + .build()) + .build(); } /** @@ -258,6 +284,27 @@ public class Directives { .definition(EXPERIMENTAL_DISABLE_ERROR_PROPAGATION_DIRECTIVE_DEFINITION) .build(); + /** + * The "@semanticNonNull" directive indicates that a field is semantically non-null: it is only null if there is a + * matching error in the `errors` array. + *

+ * See the Apollo nullability specification + */ + @ExperimentalApi + public static final GraphQLDirective SemanticNonNullDirective = GraphQLDirective.newDirective() + .name(SEMANTIC_NON_NULL) + .description("Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.") + .argument(newArgument() + .name("levels") + .type(nonNull(list(nonNull(GraphQLInt)))) + .defaultValueLiteral(ArrayValue.newArrayValue() + .value(IntValue.newIntValue(BigInteger.ZERO).build()) + .build()) + .description("The list dimensions that are semantically non null, with 0 being the outermost position.")) + .validLocations(FIELD_DEFINITION) + .definition(SEMANTIC_NON_NULL_DIRECTIVE_DEFINITION) + .build(); + /** * The set of all built-in directives that are always present in a graphql schema. * The iteration order is stable and meaningful. @@ -278,6 +325,7 @@ public class Directives { directives.add(OneOfDirective); directives.add(DeferDirective); directives.add(ExperimentalDisableErrorPropagationDirective); + directives.add(SemanticNonNullDirective); BUILT_IN_DIRECTIVES = Collections.unmodifiableSet(directives); LinkedHashMap map = new LinkedHashMap<>(); @@ -332,4 +380,25 @@ public static boolean isExperimentalDisableErrorPropagationDirectiveEnabled() { public static void setExperimentalDisableErrorPropagationEnabled(boolean flag) { EXPERIMENTAL_DISABLE_ERROR_PROPAGATION_DIRECTIVE_ENABLED.set(flag); } + + private static final AtomicBoolean SEMANTIC_NON_NULL_DIRECTIVE_ENABLED = new AtomicBoolean(true); + + /** + * This can be used to get the state of the `@semanticNonNull` directive support on a JVM wide basis. + * + * @return true if the `@semanticNonNull` directive will be respected. + */ + public static boolean isSemanticNonNullEnabled() { + return SEMANTIC_NON_NULL_DIRECTIVE_ENABLED.get(); + } + + /** + * This can be used to disable the `@semanticNonNull` directive support on a JVM wide basis in case your server + * implementation does NOT want to return an error when a semantically non null position resolves to null. + * + * @param flag the desired state of the flag + */ + public static void setSemanticNonNullEnabled(boolean flag) { + SEMANTIC_NON_NULL_DIRECTIVE_ENABLED.set(flag); + } } diff --git a/src/main/java/graphql/execution/NonNullableFieldValidator.java b/src/main/java/graphql/execution/NonNullableFieldValidator.java index 4680d9a7b..fe5c6de1e 100644 --- a/src/main/java/graphql/execution/NonNullableFieldValidator.java +++ b/src/main/java/graphql/execution/NonNullableFieldValidator.java @@ -1,18 +1,30 @@ package graphql.execution; +import com.google.common.collect.ImmutableList; +import graphql.Directives; import graphql.GraphQLError; import graphql.Internal; +import graphql.schema.GraphQLAppliedDirective; +import graphql.schema.GraphQLAppliedDirectiveArgument; +import graphql.schema.GraphQLFieldDefinition; + +import java.util.List; /** * This will check that a value is non-null when the type definition says it must be and, it will throw {@link NonNullableFieldWasNullException} * if this is not the case. + *

+ * It also enforces the {@code @semanticNonNull} directive (see the Apollo nullability specification): + * when a position annotated with {@code @semanticNonNull} resolves to null without a matching error, an error is synthesized while leaving the value null. * * See: https://spec.graphql.org/October2021/#sec-Errors-and-Non-Nullability */ @Internal public class NonNullableFieldValidator { + private static final List DEFAULT_SEMANTIC_NON_NULL_LEVELS = ImmutableList.of(0); + private final ExecutionContext executionContext; public NonNullableFieldValidator(ExecutionContext executionContext) { @@ -50,17 +62,73 @@ public T validate(ExecutionStrategyParameters parameters, T result) throws N NonNullableFieldWasNullException nonNullException = new NonNullableFieldWasNullException(executionStepInfo, path); final GraphQLError error = new NonNullableFieldWasNullError(nonNullException); - if(parameters.getAlternativeCallContext() != null) { - parameters.getAlternativeCallContext().addError(error); - } else { - executionContext.addError(error, path); - } + addError(parameters, error, path); if (executionContext.propagateErrorsOnNonNullContractFailure()) { throw nonNullException; } + } else { + checkSemanticNonNull(parameters, executionStepInfo); } } return result; } + /** + * The {@code @semanticNonNull} directive marks a position as only null when there is a matching error. When the + * position is nullable in the type system but resolves to null, we synthesize an error so the contract is upheld. + * Unlike a real non-null type the value itself stays null - the null is not propagated to the parent. + */ + private void checkSemanticNonNull(ExecutionStrategyParameters parameters, ExecutionStepInfo executionStepInfo) { + if (!Directives.isSemanticNonNullEnabled()) { + return; + } + GraphQLFieldDefinition fieldDefinition = executionStepInfo.getFieldDefinition(); + if (fieldDefinition == null) { + return; + } + GraphQLAppliedDirective directive = fieldDefinition.getAppliedDirective(Directives.SemanticNonNullDirective.getName()); + if (directive == null) { + return; + } + final ResultPath path = parameters.getPath(); + if (!semanticNonNullLevels(directive).contains(listLevel(path))) { + return; + } + + GraphQLError error = new SemanticNonNullFieldWasNullError(executionStepInfo, path); + addError(parameters, error, path); + } + + /** + * The semantic non-null level is the number of list dimensions traversed from the field, with 0 being the + * outermost position. It is the count of trailing list segments in the path. + */ + private static int listLevel(ResultPath path) { + int level = 0; + ResultPath current = path; + while (current != null && current.isListSegment()) { + level++; + current = current.getParent(); + } + return level; + } + + private static List semanticNonNullLevels(GraphQLAppliedDirective directive) { + GraphQLAppliedDirectiveArgument levels = directive.getArgument("levels"); + if (levels != null && levels.getArgumentValue().isSet()) { + List value = levels.getValue(); + if (value != null) { + return value; + } + } + return DEFAULT_SEMANTIC_NON_NULL_LEVELS; + } + + private void addError(ExecutionStrategyParameters parameters, GraphQLError error, ResultPath path) { + if (parameters.getAlternativeCallContext() != null) { + parameters.getAlternativeCallContext().addError(error); + } else { + executionContext.addError(error, path); + } + } } diff --git a/src/main/java/graphql/execution/SemanticNonNullFieldWasNullError.java b/src/main/java/graphql/execution/SemanticNonNullFieldWasNullError.java new file mode 100644 index 000000000..37afe5484 --- /dev/null +++ b/src/main/java/graphql/execution/SemanticNonNullFieldWasNullError.java @@ -0,0 +1,70 @@ +package graphql.execution; + +import graphql.ErrorType; +import graphql.GraphQLError; +import graphql.GraphqlErrorHelper; +import graphql.Internal; +import graphql.language.SourceLocation; + +import java.util.List; + +import static java.lang.String.format; + +/** + * This error is synthesized when a position annotated with the {@code @semanticNonNull} directive resolves to null + * without a matching error already being present in the {@code errors} array. + * + * See the Apollo nullability specification + */ +@Internal +public class SemanticNonNullFieldWasNullError implements GraphQLError { + + private final String message; + private final List path; + + public SemanticNonNullFieldWasNullError(ExecutionStepInfo executionStepInfo, ResultPath path) { + this.message = format("The field at path '%s' was declared as semantically non null via the @semanticNonNull directive," + + " but the code involved in retrieving data has returned a null value with no matching error." + + " The semantically non-null type is '%s'.", path, executionStepInfo.getUnwrappedNonNullType()); + this.path = path.toList(); + } + + @Override + public String getMessage() { + return message; + } + + @Override + public List getPath() { + return path; + } + + @Override + public List getLocations() { + return null; + } + + @Override + public ErrorType getErrorType() { + return ErrorType.NullValueInNonNullableField; + } + + @Override + public String toString() { + return "SemanticNonNullError{" + + "message='" + message + '\'' + + ", path=" + path + + '}'; + } + + @SuppressWarnings("EqualsWhichDoesntCheckParameterClass") + @Override + public boolean equals(Object o) { + return GraphqlErrorHelper.equals(this, o); + } + + @Override + public int hashCode() { + return GraphqlErrorHelper.hashCode(this); + } +} diff --git a/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java b/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java index 1399c52c3..d32f8655a 100644 --- a/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java +++ b/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java @@ -84,6 +84,7 @@ import static graphql.Directives.IncludeDirective; import static graphql.Directives.NO_LONGER_SUPPORTED; import static graphql.Directives.ONE_OF_DIRECTIVE_DEFINITION; +import static graphql.Directives.SEMANTIC_NON_NULL_DIRECTIVE_DEFINITION; import static graphql.Directives.SPECIFIED_BY_DIRECTIVE_DEFINITION; import static graphql.Directives.SkipDirective; import static graphql.Directives.SpecifiedByDirective; @@ -1099,6 +1100,7 @@ void addDirectivesIncludedByDefault(TypeDefinitionRegistry typeRegistry) { typeRegistry.add(DEPRECATED_DIRECTIVE_DEFINITION); typeRegistry.add(SPECIFIED_BY_DIRECTIVE_DEFINITION); typeRegistry.add(ONE_OF_DIRECTIVE_DEFINITION); + typeRegistry.add(SEMANTIC_NON_NULL_DIRECTIVE_DEFINITION); } private Optional getOperationNamed(String name, Map operationTypeDefs) { diff --git a/src/test/groovy/graphql/Issue2141.groovy b/src/test/groovy/graphql/Issue2141.groovy index e7e53d596..4fc9406b1 100644 --- a/src/test/groovy/graphql/Issue2141.groovy +++ b/src/test/groovy/graphql/Issue2141.groovy @@ -50,6 +50,12 @@ directive @include( "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." diff --git a/src/test/groovy/graphql/StarWarsIntrospectionTests.groovy b/src/test/groovy/graphql/StarWarsIntrospectionTests.groovy index 75411f6af..308e97e60 100644 --- a/src/test/groovy/graphql/StarWarsIntrospectionTests.groovy +++ b/src/test/groovy/graphql/StarWarsIntrospectionTests.groovy @@ -16,6 +16,7 @@ class StarWarsIntrospectionTests extends Specification { [name: 'Episode'], [name: 'Human'], [name: 'HumanInput'], + [name: 'Int'], [name: 'MutationType'], [name: 'QueryType'], [name: 'String'], @@ -429,7 +430,7 @@ class StarWarsIntrospectionTests extends Specification { schemaParts.get('queryType').size() == 1 schemaParts.get('mutationType').size() == 1 schemaParts.get('subscriptionType') == null - schemaParts.get('types').size() == 17 - schemaParts.get('directives').size() == 7 + schemaParts.get('types').size() == 18 + schemaParts.get('directives').size() == 8 } } diff --git a/src/test/groovy/graphql/execution/SemanticNonNullTest.groovy b/src/test/groovy/graphql/execution/SemanticNonNullTest.groovy new file mode 100644 index 000000000..e27afb2f5 --- /dev/null +++ b/src/test/groovy/graphql/execution/SemanticNonNullTest.groovy @@ -0,0 +1,183 @@ +package graphql.execution + +import graphql.Directives +import graphql.ExecutionInput +import graphql.TestUtil +import graphql.schema.DataFetcher +import spock.lang.Specification + +class SemanticNonNullTest extends Specification { + + void setup() { + Directives.setSemanticNonNullEnabled(true) + } + + void cleanup() { + Directives.setSemanticNonNullEnabled(true) + } + + def "emits an error when a @semanticNonNull scalar resolves to null"() { + def sdl = ''' + type Query { + foo : Int @semanticNonNull + } + ''' + def graphql = TestUtil.graphQL(sdl).build() + + when: + def ei = ExecutionInput.newExecutionInput("{ foo }").root([foo: null]).build() + def er = graphql.execute(ei) + + then: + er.data.foo == null + er.errors.size() == 1 + er.errors[0].path.toList() == ["foo"] + er.errors[0].message.contains("semantically non null") + } + + def "no error when a @semanticNonNull field resolves to a non null value"() { + def sdl = ''' + type Query { + foo : Int @semanticNonNull + } + ''' + def graphql = TestUtil.graphQL(sdl).build() + + when: + def ei = ExecutionInput.newExecutionInput("{ foo }").root([foo: 42]).build() + def er = graphql.execute(ei) + + then: + er.data.foo == 42 + er.errors.isEmpty() + } + + def "does not emit a second error when one already exists for that path"() { + def sdl = ''' + type Query { + foo : Int @semanticNonNull + } + ''' + DataFetcher df = { env -> throw new RuntimeException("boom") } + def graphql = TestUtil.graphQL(sdl, [Query: [foo: df]]).build() + + when: + def er = graphql.execute("{ foo }") + + then: + er.data.foo == null + er.errors.size() == 1 + er.errors[0].path.toList() == ["foo"] + er.errors[0].message.contains("boom") + } + + def "the value is not propagated to the parent like a real non null would"() { + def sdl = ''' + type Query { + bar : Bar + } + type Bar { + foo : Int @semanticNonNull + } + ''' + def graphql = TestUtil.graphQL(sdl).build() + + when: + def ei = ExecutionInput.newExecutionInput("{ bar { foo } }").root([bar: [foo: null]]).build() + def er = graphql.execute(ei) + + then: + er.data.bar != null + er.data.bar.foo == null + er.errors.size() == 1 + er.errors[0].path.toList() == ["bar", "foo"] + er.errors[0].message.contains("semantically non null") + } + + def "honours the levels argument for list elements"() { + def sdl = ''' + type Query { + foo : [Int] @semanticNonNull(levels: [1]) + } + ''' + def graphql = TestUtil.graphQL(sdl).build() + + when: + def ei = ExecutionInput.newExecutionInput("{ foo }").root([foo: [1, null, 3]]).build() + def er = graphql.execute(ei) + + then: + er.data.foo == [1, null, 3] + er.errors.size() == 1 + er.errors[0].path.toList() == ["foo", 1] + er.errors[0].message.contains("semantically non null") + } + + def "does not synthesize an error for a null list element when only the list itself is semantically non null"() { + def sdl = ''' + type Query { + foo : [Int] @semanticNonNull + } + ''' + def graphql = TestUtil.graphQL(sdl).build() + + when: + def ei = ExecutionInput.newExecutionInput("{ foo }").root([foo: [1, null, 3]]).build() + def er = graphql.execute(ei) + + then: + er.data.foo == [1, null, 3] + er.errors.isEmpty() + } + + def "emits an error when a @semanticNonNull list itself is null at level 0"() { + def sdl = ''' + type Query { + foo : [Int] @semanticNonNull + } + ''' + def graphql = TestUtil.graphQL(sdl).build() + + when: + def ei = ExecutionInput.newExecutionInput("{ foo }").root([foo: null]).build() + def er = graphql.execute(ei) + + then: + er.data.foo == null + er.errors.size() == 1 + er.errors[0].path.toList() == ["foo"] + er.errors[0].message.contains("semantically non null") + } + + def "does nothing when the JVM wide flag is disabled"() { + def sdl = ''' + type Query { + foo : Int @semanticNonNull + } + ''' + def graphql = TestUtil.graphQL(sdl).build() + + when: + Directives.setSemanticNonNullEnabled(false) + def ei = ExecutionInput.newExecutionInput("{ foo }").root([foo: null]).build() + def er = graphql.execute(ei) + + then: + er.data.foo == null + er.errors.isEmpty() + } + + def "@semanticNonNull does not need to be declared in the SDL"() { + def sdl = ''' + type Query { + foo : Int @semanticNonNull + } + ''' + + when: + def graphql = TestUtil.graphQL(sdl).build() + + then: + graphql.getGraphQLSchema().getDirective(Directives.SemanticNonNullDirective.getName()) != null + } +} diff --git a/src/test/groovy/graphql/introspection/IntrospectionWithDirectivesSupportTest.groovy b/src/test/groovy/graphql/introspection/IntrospectionWithDirectivesSupportTest.groovy index 551517a8a..89544a94f 100644 --- a/src/test/groovy/graphql/introspection/IntrospectionWithDirectivesSupportTest.groovy +++ b/src/test/groovy/graphql/introspection/IntrospectionWithDirectivesSupportTest.groovy @@ -92,7 +92,7 @@ class IntrospectionWithDirectivesSupportTest extends Specification { schemaType["directives"] == [ [name: "include"], [name: "skip"], [name: "defer"], [name: "experimental_disableErrorPropagation"], [name: "example"], [name: "secret"], [name: "noDefault"], - [name: "deprecated"], [name: "specifiedBy"], [name: "oneOf"] + [name: "deprecated"], [name: "specifiedBy"], [name: "oneOf"], [name: "semanticNonNull"] ] schemaType["appliedDirectives"] == [[name: "example", args: [[name: "argName", value: '"onSchema"']]]] @@ -175,7 +175,7 @@ class IntrospectionWithDirectivesSupportTest extends Specification { def definedDirectives = er.data["__schema"]["directives"] // secret is filter out definedDirectives == [[name: "include"], [name: "skip"], [name: "defer"], [name: "experimental_disableErrorPropagation"], - [name: "example"], [name: "deprecated"], [name: "specifiedBy"], [name: "oneOf"] + [name: "example"], [name: "deprecated"], [name: "specifiedBy"], [name: "oneOf"], [name: "semanticNonNull"] ] } diff --git a/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy b/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy index e04e8e2a6..7fef683ec 100644 --- a/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy +++ b/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy @@ -155,8 +155,8 @@ class GraphQLSchemaTest extends Specification { when: "a schema is built" def schema = schemaBuilder.build() - then: "all 7 built-in directives are present" - schema.directives.size() == 7 + then: "all 8 built-in directives are present" + schema.directives.size() == 8 schema.getDirective("include") != null schema.getDirective("skip") != null schema.getDirective("deprecated") != null @@ -164,16 +164,17 @@ class GraphQLSchemaTest extends Specification { schema.getDirective("oneOf") != null schema.getDirective("defer") != null schema.getDirective("experimental_disableErrorPropagation") != null + schema.getDirective("semanticNonNull") != null when: "the schema is transformed, things are copied" schema = schema.transform({ builder -> builder }) - then: "all 7 built-in directives are still present" - schema.directives.size() == 7 + then: "all 8 built-in directives are still present" + schema.directives.size() == 8 when: "clearDirectives is called" schema = basicSchemaBuilder().clearDirectives().build() - then: "all 7 built-in directives are still present because ensureBuiltInDirectives re-adds them" - schema.directives.size() == 7 + then: "all 8 built-in directives are still present because ensureBuiltInDirectives re-adds them" + schema.directives.size() == 8 when: "clearDirectives is called and additional directives are added" schema = basicSchemaBuilder().clearDirectives() @@ -182,8 +183,8 @@ class GraphQLSchemaTest extends Specification { .validLocations(DirectiveLocation.FIELD) .build()) .build() - then: "all 7 built-in directives are present plus the additional one" - schema.directives.size() == 8 + then: "all 8 built-in directives are present plus the additional one" + schema.directives.size() == 9 schema.getDirective("custom") != null } @@ -275,7 +276,7 @@ class GraphQLSchemaTest extends Specification { def schema = basicSchemaBuilder() .additionalDirective(originalDirective) .build() - assert schema.directives.size() == 8 + assert schema.directives.size() == 9 when: "the schema is transformed to replace the custom directive" def replacementDirective = GraphQLDirective.newDirective() @@ -290,8 +291,8 @@ class GraphQLSchemaTest extends Specification { .additionalDirectives(nonBuiltIns) }) - then: "all 7 built-in directives are still present" - newSchema.directives.size() == 8 + then: "all 8 built-in directives are still present" + newSchema.directives.size() == 9 newSchema.getDirective("include") != null newSchema.getDirective("skip") != null newSchema.getDirective("deprecated") != null @@ -320,7 +321,7 @@ class GraphQLSchemaTest extends Specification { then: "unoverridden built-ins come first (in BUILT_IN_DIRECTIVES order, skip excluded), then user-supplied in insertion order" def names = schema.directives.collect { it.name } names == ["include", "deprecated", "specifiedBy", "oneOf", "defer", - "experimental_disableErrorPropagation", "custom", "skip"] + "experimental_disableErrorPropagation", "semanticNonNull", "custom", "skip"] and: "the customized skip directive retains its custom description" schema.getDirective("skip").description == "custom skip description" diff --git a/src/test/groovy/graphql/schema/GraphqlTypeComparatorsTest.groovy b/src/test/groovy/graphql/schema/GraphqlTypeComparatorsTest.groovy index ba93f1d2e..2fdf6316f 100644 --- a/src/test/groovy/graphql/schema/GraphqlTypeComparatorsTest.groovy +++ b/src/test/groovy/graphql/schema/GraphqlTypeComparatorsTest.groovy @@ -75,7 +75,7 @@ class GraphqlTypeComparatorsTest extends Specification { def schemaAsIs = TestUtil.schema(spec, runtimeWiringAsIs) def "test that types sorted from the schema"() { - def expectedNames = ["Bar", "Boolean", "EnumType", "Foo", "InputType", "Query", "String", "XInterface", "XUnion", "YInterface", "ZInterface", "ZType", + def expectedNames = ["Bar", "Boolean", "EnumType", "Foo", "InputType", "Int", "Query", "String", "XInterface", "XUnion", "YInterface", "ZInterface", "ZType", "__Directive", "__DirectiveLocation", "__EnumValue", "__Field", "__InputValue", "__Schema", "__Type", "__TypeKind"] when: def names = schemaByName.getAllTypesAsList().collect({ thing -> thing.getName() }) diff --git a/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy b/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy index 0465c3e27..eb1215c74 100644 --- a/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy +++ b/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy @@ -33,15 +33,15 @@ class SchemaDiffingTest extends Specification { schemaGraph.getVerticesByType(SchemaGraph.ENUM_VALUE).size() == 28 schemaGraph.getVerticesByType(SchemaGraph.INTERFACE).size() == 0 schemaGraph.getVerticesByType(SchemaGraph.UNION).size() == 0 - schemaGraph.getVerticesByType(SchemaGraph.SCALAR).size() == 2 + schemaGraph.getVerticesByType(SchemaGraph.SCALAR).size() == 3 schemaGraph.getVerticesByType(SchemaGraph.FIELD).size() == 42 - schemaGraph.getVerticesByType(SchemaGraph.ARGUMENT).size() == 12 + schemaGraph.getVerticesByType(SchemaGraph.ARGUMENT).size() == 13 schemaGraph.getVerticesByType(SchemaGraph.INPUT_FIELD).size() == 0 schemaGraph.getVerticesByType(SchemaGraph.INPUT_OBJECT).size() == 0 - schemaGraph.getVerticesByType(SchemaGraph.DIRECTIVE).size() == 7 + schemaGraph.getVerticesByType(SchemaGraph.DIRECTIVE).size() == 8 schemaGraph.getVerticesByType(SchemaGraph.APPLIED_ARGUMENT).size() == 0 schemaGraph.getVerticesByType(SchemaGraph.APPLIED_DIRECTIVE).size() == 0 - schemaGraph.size() == 101 + schemaGraph.size() == 104 } @@ -920,7 +920,7 @@ class SchemaDiffingTest extends Specification { """) def schema2 = schema(""" type Query { - foo(arg: Int): String + foo(arg: Float): String } """) @@ -935,7 +935,7 @@ class SchemaDiffingTest extends Specification { given: def schema1 = schema(""" type Query { - foo(arg: Int): String + foo(arg: Float): String } """) def schema2 = schema(""" diff --git a/src/test/groovy/graphql/schema/idl/SchemaGeneratorAppliedDirectiveHelperTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaGeneratorAppliedDirectiveHelperTest.groovy index f42cc4905..255ccaed1 100644 --- a/src/test/groovy/graphql/schema/idl/SchemaGeneratorAppliedDirectiveHelperTest.groovy +++ b/src/test/groovy/graphql/schema/idl/SchemaGeneratorAppliedDirectiveHelperTest.groovy @@ -61,6 +61,7 @@ class SchemaGeneratorAppliedDirectiveHelperTest extends Specification { "foo", "include", "oneOf", + "semanticNonNull", "skip", "specifiedBy", ] @@ -113,6 +114,7 @@ class SchemaGeneratorAppliedDirectiveHelperTest extends Specification { "foo", "include", "oneOf", + "semanticNonNull", "skip", "specifiedBy", ] diff --git a/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy index a3d847be4..3d9e3672b 100644 --- a/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy +++ b/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy @@ -2085,7 +2085,7 @@ class SchemaGeneratorTest extends Specification { directives = schema.getDirectives() then: - directives.size() == 10 // built in ones : include / skip and deprecated + directives.size() == 11 // built in ones : include / skip and deprecated def directiveNames = directives.collect { it.name } directiveNames.contains("include") directiveNames.contains("skip") @@ -2093,6 +2093,7 @@ class SchemaGeneratorTest extends Specification { directiveNames.contains("deprecated") directiveNames.contains("specifiedBy") directiveNames.contains("oneOf") + directiveNames.contains("semanticNonNull") directiveNames.contains("sd1") directiveNames.contains("sd2") directiveNames.contains("sd3") @@ -2101,12 +2102,13 @@ class SchemaGeneratorTest extends Specification { directivesMap = schema.getDirectivesByName() then: - directivesMap.size() == 10 // built in ones + directivesMap.size() == 11 // built in ones directivesMap.containsKey("include") directivesMap.containsKey("skip") directivesMap.containsKey("defer") directivesMap.containsKey("deprecated") directivesMap.containsKey("oneOf") + directivesMap.containsKey("semanticNonNull") directivesMap.containsKey("sd1") directivesMap.containsKey("sd2") directivesMap.containsKey("sd3") diff --git a/src/test/groovy/graphql/schema/idl/SchemaPrinterTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaPrinterTest.groovy index c69d4b4ce..a8abfb897 100644 --- a/src/test/groovy/graphql/schema/idl/SchemaPrinterTest.groovy +++ b/src/test/groovy/graphql/schema/idl/SchemaPrinterTest.groovy @@ -1020,6 +1020,12 @@ directive @repeatableDirective repeatable on SCALAR directive @scalarDirective on SCALAR +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + directive @single on OBJECT directive @singleField on FIELD_DEFINITION @@ -1179,6 +1185,12 @@ directive @include( "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." @@ -1290,6 +1302,12 @@ directive @moreComplex(arg1: String = "default", arg2: Int) on FIELD_DEFINITION "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." @@ -1369,6 +1387,12 @@ directive @moreComplex(arg1: String = "default", arg2: Int) on FIELD_DEFINITION "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." @@ -1475,6 +1499,12 @@ directive @oneOf on INPUT_OBJECT directive @schemaDirective on SCHEMA +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." @@ -1572,6 +1602,12 @@ directive @oneOf on INPUT_OBJECT directive @schemaDirective on SCHEMA +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." @@ -1723,6 +1759,12 @@ directive @include( "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." @@ -2270,6 +2312,12 @@ directive @include( "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." @@ -2476,6 +2524,12 @@ directive @skip( if: Boolean! ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT @@ -2642,6 +2696,12 @@ directive @include( "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." @@ -2889,6 +2949,12 @@ directive @include( "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." @@ -3085,6 +3151,12 @@ directive @include( "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Exposes a URL that specifies the behaviour of this scalar." directive @specifiedBy( "The URL that specifies the behaviour of this scalar." diff --git a/src/test/groovy/graphql/schema/impl/SchemaUtilTest.groovy b/src/test/groovy/graphql/schema/impl/SchemaUtilTest.groovy index 41d9343da..37ad52058 100644 --- a/src/test/groovy/graphql/schema/impl/SchemaUtilTest.groovy +++ b/src/test/groovy/graphql/schema/impl/SchemaUtilTest.groovy @@ -22,6 +22,7 @@ import graphql.util.TraverserContext import spock.lang.Specification import static graphql.Scalars.GraphQLBoolean +import static graphql.Scalars.GraphQLID import static graphql.Scalars.GraphQLInt import static graphql.Scalars.GraphQLString import static graphql.StarWarsSchema.characterInterface @@ -62,7 +63,7 @@ class SchemaUtilTest extends Specification { SchemaUtil.visitPartiallySchema(starWarsSchema, collectingVisitor) Map types = collectingVisitor.getResult() then: - types.size() == 17 + types.size() == 18 types == [(droidType.name) : droidType, (humanType.name) : humanType, (queryType.name) : queryType, @@ -71,6 +72,7 @@ class SchemaUtilTest extends Specification { (inputHumanType.name) : inputHumanType, (episodeEnum.name) : episodeEnum, (GraphQLString.name) : GraphQLString, + (GraphQLInt.name) : GraphQLInt, (Introspection.__Schema.name) : Introspection.__Schema, (Introspection.__Type.name) : Introspection.__Type, (Introspection.__TypeKind.name) : Introspection.__TypeKind, @@ -114,7 +116,7 @@ class SchemaUtilTest extends Specification { Map types = collectingVisitor.getResult() then: - types.size() == 30 + types.size() == 31 types.containsValue(UnionDirectiveInput) types.containsValue(InputObjectDirectiveInput) types.containsValue(ObjectDirectiveInput) diff --git a/src/test/resources/large-schema-5.graphqls.part1 b/src/test/resources/large-schema-5.graphqls.part1 index 144ef560a..af557913c 100644 --- a/src/test/resources/large-schema-5.graphqls.part1 +++ b/src/test/resources/large-schema-5.graphqls.part1 @@ -190,6 +190,12 @@ directive @include( "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "Directs the executor to skip this field or fragment when the `if` argument is true." directive @skip( "Skipped when true." diff --git a/src/test/resources/large-schema-federated-1.graphqls b/src/test/resources/large-schema-federated-1.graphqls index d04656ba0..a2e74e705 100644 --- a/src/test/resources/large-schema-federated-1.graphqls +++ b/src/test/resources/large-schema-federated-1.graphqls @@ -39,6 +39,12 @@ directive @nonNull on FIELD "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "For federated schema composition. When a service wants to expose a field that another service already exposes, it may use this directive to claim the field." directive @override( "Service to override the field from." diff --git a/src/test/resources/large-schema-federated-2.graphqls b/src/test/resources/large-schema-federated-2.graphqls index 229032686..dd4d624f8 100644 --- a/src/test/resources/large-schema-federated-2.graphqls +++ b/src/test/resources/large-schema-federated-2.graphqls @@ -39,6 +39,12 @@ directive @nonNull on FIELD "Indicates an Input Object is a OneOf Input Object." directive @oneOf on INPUT_OBJECT +"Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array." +directive @semanticNonNull( + "The list dimensions that are semantically non null, with 0 being the outermost position." + levels: [Int!]! = [0] + ) on FIELD_DEFINITION + "For federated schema composition. When a service wants to expose a field that another service already exposes, it may use this directive to claim the field." directive @override( "Service to override the field from."