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