Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/main/java/graphql/Directives.java
Original file line number Diff line number Diff line change
@@ -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.ENUM_VALUE;
Expand All @@ -34,6 +40,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;

/**
Expand All @@ -50,6 +57,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;
Expand All @@ -61,6 +70,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";
Expand Down Expand Up @@ -155,6 +166,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();
}

/**
Expand Down Expand Up @@ -256,6 +282,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.
* <p>
* See <a href="https://specs.apollo.dev/nullability/v0.4/">the Apollo nullability specification</a>
*/
@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.")
Comment thread
martinbonnin marked this conversation as resolved.
.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.
Expand All @@ -276,6 +323,7 @@ public class Directives {
directives.add(OneOfDirective);
directives.add(DeferDirective);
directives.add(ExperimentalDisableErrorPropagationDirective);
directives.add(SemanticNonNullDirective);
BUILT_IN_DIRECTIVES = Collections.unmodifiableSet(directives);

LinkedHashMap<String, GraphQLDirective> map = new LinkedHashMap<>();
Expand Down Expand Up @@ -330,4 +378,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);
}
}
78 changes: 73 additions & 5 deletions src/main/java/graphql/execution/NonNullableFieldValidator.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* It also enforces the {@code @semanticNonNull} directive (see <a href="https://specs.apollo.dev/nullability/v0.4/">the Apollo nullability specification</a>):
* 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<Integer> DEFAULT_SEMANTIC_NON_NULL_LEVELS = ImmutableList.of(0);

private final ExecutionContext executionContext;

public NonNullableFieldValidator(ExecutionContext executionContext) {
Expand Down Expand Up @@ -50,17 +62,73 @@ public <T> 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);
}
Comment thread
martinbonnin marked this conversation as resolved.
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<Integer> semanticNonNullLevels(GraphQLAppliedDirective directive) {
GraphQLAppliedDirectiveArgument levels = directive.getArgument("levels");
if (levels != null && levels.getArgumentValue().isSet()) {
List<Integer> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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 <a href="https://specs.apollo.dev/nullability/v0.4/">the Apollo nullability specification</a>
*/
@Internal
public class SemanticNonNullFieldWasNullError implements GraphQLError {

private final String message;
private final List<Object> 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<Object> getPath() {
return path;
}

@Override
public List<SourceLocation> 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);
}
}
2 changes: 2 additions & 0 deletions src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<OperationTypeDefinition> getOperationNamed(String name, Map<String, OperationTypeDefinition> operationTypeDefs) {
Expand Down
6 changes: 6 additions & 0 deletions src/test/groovy/graphql/Issue2141.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
5 changes: 3 additions & 2 deletions src/test/groovy/graphql/StarWarsIntrospectionTests.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class StarWarsIntrospectionTests extends Specification {
[name: 'Episode'],
[name: 'Human'],
[name: 'HumanInput'],
[name: 'Int'],
[name: 'MutationType'],
[name: 'QueryType'],
[name: 'String'],
Expand Down Expand Up @@ -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
}
}
Loading
Loading