diff --git a/.idea/runConfigurations/ValidationTest.xml b/.idea/runConfigurations/ValidationTest.xml
index e9823f56592..1c90df1e5fe 100644
--- a/.idea/runConfigurations/ValidationTest.xml
+++ b/.idea/runConfigurations/ValidationTest.xml
@@ -1,10 +1,15 @@
+
-
+
@@ -20,6 +25,7 @@
falsetruefalse
+ true
\ No newline at end of file
diff --git a/intellij-plugin/src/main/resources/inspectionDescriptions/ApolloMissingGraphQLDefinitionImport.html b/intellij-plugin/src/main/resources/inspectionDescriptions/ApolloMissingGraphQLDefinitionImport.html
index afa0278228e..8efaec8d915 100644
--- a/intellij-plugin/src/main/resources/inspectionDescriptions/ApolloMissingGraphQLDefinitionImport.html
+++ b/intellij-plugin/src/main/resources/inspectionDescriptions/ApolloMissingGraphQLDefinitionImport.html
@@ -4,11 +4,11 @@
Before being referenced, directives and types supported by Apollo Kotlin must be imported by your schema using the @link directive .
For instance, to use the @semanticNonNull directive, import it from the
- nullability definitions:
+ nullability definitions:
diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra.graphqls
index e2f546afeb2..6eb81e68462 100644
--- a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra.graphqls
+++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra.graphqls
@@ -1,5 +1,5 @@
extend schema
@link(
- url: "https://specs.apollo.dev/nullability/v0.1",
+ url: "https://specs.apollo.dev/nullability/v0.2",
import: ["@catch"]
)
diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra_after.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra_after.graphqls
index 616469b20a8..81e1dd44548 100644
--- a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra_after.graphqls
+++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra_after.graphqls
@@ -1,5 +1,5 @@
extend schema
@link(
- url: "https://specs.apollo.dev/nullability/v0.1",
+ url: "https://specs.apollo.dev/nullability/v0.2",
import: ["@catch", "CatchTo"]
)
diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch-extra_after.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch-extra_after.graphqls
index 3cbe73d3883..5cc2a398b80 100644
--- a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch-extra_after.graphqls
+++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch-extra_after.graphqls
@@ -1,5 +1,5 @@
extend schema
@link(
- url: "https://specs.apollo.dev/nullability/v0.1",
+ url: "https://specs.apollo.dev/nullability/v0.2",
import: ["@catch", "CatchTo"]
)
\ No newline at end of file
diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra.graphqls
index f61b7bf5e45..e4e0a7b9e07 100644
--- a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra.graphqls
+++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra.graphqls
@@ -1,6 +1,6 @@
extend schema
@link(
- url: "https://specs.apollo.dev/nullability/v0.1",
+ url: "https://specs.apollo.dev/nullability/v0.2",
import: ["@catch", "CatchTo"]
)
diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra_after.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra_after.graphqls
index d070ad01688..8ccb6b45669 100644
--- a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra_after.graphqls
+++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra_after.graphqls
@@ -6,7 +6,7 @@ extend schema
extend schema
@link(
- url: "https://specs.apollo.dev/nullability/v0.1",
+ url: "https://specs.apollo.dev/nullability/v0.2",
import: ["@catch", "CatchTo"]
)
diff --git a/libraries/apollo-ast/api/apollo-ast.api b/libraries/apollo-ast/api/apollo-ast.api
index e282905572b..ed6ea6206ea 100644
--- a/libraries/apollo-ast/api/apollo-ast.api
+++ b/libraries/apollo-ast/api/apollo-ast.api
@@ -942,6 +942,7 @@ public final class com/apollographql/apollo3/ast/Schema {
public static final field OPTIONAL Ljava/lang/String;
public static final field REQUIRES_OPT_IN Ljava/lang/String;
public static final field SEMANTIC_NON_NULL Ljava/lang/String;
+ public static final field SEMANTIC_NON_NULL_FIELD Ljava/lang/String;
public static final field TYPE_POLICY Ljava/lang/String;
public final fun getDirectiveDefinitions ()Ljava/util/Map;
public final fun getErrorAware ()Z
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/Schema.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/Schema.kt
index 622ee7c2286..3d66e9448e8 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/Schema.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/Schema.kt
@@ -212,6 +212,8 @@ class Schema internal constructor(
@ApolloExperimental
const val SEMANTIC_NON_NULL = "semanticNonNull"
@ApolloExperimental
+ const val SEMANTIC_NON_NULL_FIELD = "semanticNonNullField"
+ @ApolloExperimental
const val IGNORE_ERRORS = "ignoreErrors"
const val FIELD_POLICY_FOR_FIELD = "forField"
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqldirective.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqldirective.kt
index 459ded2603b..f34416e87da 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqldirective.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqldirective.kt
@@ -83,14 +83,14 @@ enum class CatchTo {
}
@ApolloInternal
-data class Catch(val to: CatchTo, val level: Int?)
+data class Catch(val to: CatchTo, val levels: List)
private fun GQLDirectiveDefinition.getArgumentDefaultValue(argName: String): GQLValue? {
return arguments.firstOrNull { it.name == argName }?.defaultValue
}
@ApolloInternal
-fun GQLDirective.getArgument(argName: String, schema: Schema): GQLValue? {
+fun GQLDirective.getArgumentValueOrDefault(argName: String, schema: Schema): GQLValue? {
val directiveDefinition: GQLDirectiveDefinition = schema.directiveDefinitions.get(name)!!
val argument = arguments.firstOrNull { it.name == argName }
if (argument == null) {
@@ -99,6 +99,18 @@ fun GQLDirective.getArgument(argName: String, schema: Schema): GQLValue? {
return argument.value
}
+private fun GQLValue.toListOfInt(): List {
+ check(this is GQLListValue) {
+ error("${sourceLocation}: expected a list value")
+ }
+ return this.values.map {
+ check(it is GQLIntValue) {
+ error("${it.sourceLocation}: expected an int value")
+ }
+ it.value.toInt()
+ }
+}
+
private fun GQLValue.toIntOrNull(): Int? {
return when (this) {
is GQLNullValue -> null
@@ -136,32 +148,39 @@ private fun GQLValue?.toCatchTo(): CatchTo {
}
@ApolloInternal
-fun List.findCatches(schema: Schema): List {
+fun List.findCatch(schema: Schema): Catch? {
return filter {
schema.originalDirectiveName(it.name) == Schema.CATCH
}.map {
Catch(
- to = it.getArgument("to", schema).toCatchTo(),
- level = it.getArgument("level", schema)?.toIntOrNull(),
+ to = it.getArgumentValueOrDefault("to", schema).toCatchTo(),
+ levels = it.getArgumentValueOrDefault("levels", schema)!!.toListOfInt(),
)
- }
+ }.singleOrNull()
}
@ApolloInternal
-fun GQLFieldDefinition.findSemanticNonNulls(schema: Schema): List {
- return directives.filter {
+fun GQLFieldDefinition.findSemanticNonNulls(schema: Schema): List {
+ val semanticNonNulls = directives.filter {
schema.originalDirectiveName(it.name) == Schema.SEMANTIC_NON_NULL
- }.map {
- it.getArgument("level", schema)?.toIntOrNull()
}
+
+ val semanticNonNull = semanticNonNulls.singleOrNull()
+ if (semanticNonNull == null) {
+ return emptyList()
+ }
+ return semanticNonNull.getArgumentValueOrDefault("levels", schema)!!.toListOfInt()
}
@ApolloInternal
-fun GQLTypeDefinition.findSemanticNonNulls(fieldName: String, schema: Schema): List {
- return directives.filter {
- schema.originalDirectiveName(it.name) == Schema.SEMANTIC_NON_NULL
- && it.getArgument("field", schema)?.toStringOrNull() == fieldName
- }.map {
- it.getArgument("level", schema)?.toIntOrNull()
+fun GQLTypeDefinition.findSemanticNonNulls(fieldName: String, schema: Schema): List {
+ val semanticNonNulls = directives.filter {
+ schema.originalDirectiveName(it.name) == Schema.SEMANTIC_NON_NULL_FIELD
+ && it.getArgumentValueOrDefault("name", schema)?.toStringOrNull() == fieldName
+ }
+ val semanticNonNull = semanticNonNulls.singleOrNull()
+ if (semanticNonNull == null) {
+ return emptyList()
}
+ return semanticNonNull.getArgumentValueOrDefault("levels", schema)!!.toListOfInt()
}
\ No newline at end of file
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqldocument.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqldocument.kt
index 156816361c1..56f54ff581d 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqldocument.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqldocument.kt
@@ -92,7 +92,7 @@ fun kotlinLabsDefinitions(version: String): List {
})
}
-@ApolloInternal const val NULLABILITY_VERSION = "v0.1"
+@ApolloInternal const val NULLABILITY_VERSION = "v0.2"
/**
* Extra nullability definitions from https://specs.apollo.dev/nullability/<[version]>
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/ExecutableValidationScope.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/ExecutableValidationScope.kt
index d759e912319..8147b1c0c51 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/ExecutableValidationScope.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/ExecutableValidationScope.kt
@@ -1,8 +1,56 @@
package com.apollographql.apollo3.ast.internal
import com.apollographql.apollo3.annotations.ApolloInternal
-import com.apollographql.apollo3.ast.*
+import com.apollographql.apollo3.ast.AnonymousOperation
+import com.apollographql.apollo3.ast.Catch
+import com.apollographql.apollo3.ast.DeprecatedUsage
+import com.apollographql.apollo3.ast.DifferentShape
+import com.apollographql.apollo3.ast.ExecutableValidationResult
+import com.apollographql.apollo3.ast.GQLArgument
+import com.apollographql.apollo3.ast.GQLBooleanValue
+import com.apollographql.apollo3.ast.GQLDirective
+import com.apollographql.apollo3.ast.GQLDocument
+import com.apollographql.apollo3.ast.GQLEnumTypeDefinition
+import com.apollographql.apollo3.ast.GQLEnumValue
+import com.apollographql.apollo3.ast.GQLField
+import com.apollographql.apollo3.ast.GQLFieldDefinition
+import com.apollographql.apollo3.ast.GQLFloatValue
+import com.apollographql.apollo3.ast.GQLFragmentDefinition
+import com.apollographql.apollo3.ast.GQLFragmentSpread
+import com.apollographql.apollo3.ast.GQLInlineFragment
+import com.apollographql.apollo3.ast.GQLIntValue
+import com.apollographql.apollo3.ast.GQLListType
+import com.apollographql.apollo3.ast.GQLListValue
+import com.apollographql.apollo3.ast.GQLNamedType
+import com.apollographql.apollo3.ast.GQLNode
+import com.apollographql.apollo3.ast.GQLNonNullType
+import com.apollographql.apollo3.ast.GQLNullValue
+import com.apollographql.apollo3.ast.GQLObjectTypeDefinition
+import com.apollographql.apollo3.ast.GQLObjectValue
+import com.apollographql.apollo3.ast.GQLOperationDefinition
+import com.apollographql.apollo3.ast.GQLScalarTypeDefinition
+import com.apollographql.apollo3.ast.GQLSelection
+import com.apollographql.apollo3.ast.GQLStringValue
+import com.apollographql.apollo3.ast.GQLType
+import com.apollographql.apollo3.ast.GQLTypeDefinition
+import com.apollographql.apollo3.ast.GQLValue
+import com.apollographql.apollo3.ast.GQLVariableValue
+import com.apollographql.apollo3.ast.Issue
+import com.apollographql.apollo3.ast.OtherValidationIssue
+import com.apollographql.apollo3.ast.Schema
+import com.apollographql.apollo3.ast.UnusedFragment
+import com.apollographql.apollo3.ast.UnusedVariable
+import com.apollographql.apollo3.ast.VariableUsage
+import com.apollographql.apollo3.ast.definitionFromScope
+import com.apollographql.apollo3.ast.findCatch
+import com.apollographql.apollo3.ast.findDeprecationReason
+import com.apollographql.apollo3.ast.getArgumentValueOrDefault
import com.apollographql.apollo3.ast.internal.validation.validateDeferLabels
+import com.apollographql.apollo3.ast.pretty
+import com.apollographql.apollo3.ast.rawType
+import com.apollographql.apollo3.ast.responseName
+import com.apollographql.apollo3.ast.rootTypeDefinition
+import com.apollographql.apollo3.ast.sharesPossibleTypesWith
@OptIn(ApolloInternal::class)
@@ -187,8 +235,6 @@ internal class ExecutableValidationScope(
}
}
- private class CatchUsage(val level: Int?, val catchTo: String, val sourceLocation: SourceLocation?)
-
private fun GQLType.maxDimension(): Int {
var dimension = 0
var type = this
@@ -209,57 +255,42 @@ internal class ExecutableValidationScope(
}
private fun GQLField.validateCatches(fieldDefinition: GQLFieldDefinition) {
- val levelToCatch = directives.filter {
+ val catches = directives.filter {
schema.originalDirectiveName(it.name) == Schema.CATCH
- }.mapNotNull {
- val to = it.getArgument("to", schema) as? GQLEnumValue
- if (to == null) {
- // caught by other validation rules
- return@mapNotNull null
- }
- val levelInt = when (val levelValue = it.getArgument("level", schema)) {
- is GQLNullValue -> null
- is GQLIntValue -> levelValue.value.toIntOrNull() ?: error("`@catch` level too big: ${levelValue.value}")
- else -> return@mapNotNull null
- }
+ }
- if (levelInt != null) {
- val maxDimension = fieldDefinition.type.maxDimension()
- if (levelInt > maxDimension) {
- registerIssue(
- message = "Invalid 'level' value '$levelInt' for `@catch` usage: this type has a max list level of $maxDimension",
- sourceLocation = it.sourceLocation
- )
- return@mapNotNull null
- }
- }
- CatchUsage(levelInt, to.value, it.sourceLocation)
- }.groupBy {
- it.level
- }.mapNotNull {
- val sameLevel = it.value.distinct()
- if (sameLevel.size > 1) {
- registerIssue(
- message = "Conflicting `@catch` usages for level '${it.key}': a given list level must have a single CatchTo value",
- sourceLocation = it.value.first().sourceLocation
- )
- return@mapNotNull null
- }
+ if (catches.size > 1) {
+ // "caught" (ahah) by other validation rules
+ return
+ }
- it.value.single()
- }.associateBy {
- it.level
+ val catch = catches.singleOrNull()
+ if (catch == null) {
+ return
}
- val default = levelToCatch[null]?.catchTo
- levelToCatch.filter {
- it.key != null
- }.forEach {
- if (default != null && it.value.catchTo != default) {
- registerIssue(
- message = "Conflicting `@catch` usages for level '${it.key}': this level conflicts with the default 'null' level",
- sourceLocation = it.value.sourceLocation
- )
+ val levels = catch.getArgumentValueOrDefault("levels", schema)
+ val maxDimension = fieldDefinition.type.maxDimension()
+ if (levels is GQLListValue) {
+ levels.values.forEach {
+ if (it is GQLIntValue) {
+ val asInt = it.value.toIntOrNull()
+ if (asInt == null) {
+ registerIssue("Invalid value: '${it.value}'", it.sourceLocation)
+ return@forEach
+ }
+ if (asInt > maxDimension) {
+ registerIssue(
+ message = "Invalid 'levels' value '$asInt' for `@catch` usage: this field has a max list level of $maxDimension",
+ sourceLocation = it.sourceLocation
+ )
+ } else if (asInt < 0) {
+ registerIssue(
+ message = "'levels' values must be positive ints",
+ sourceLocation = it.sourceLocation
+ )
+ }
+ }
}
}
}
@@ -481,7 +512,7 @@ internal class ExecutableValidationScope(
addFieldMergingIssue(fieldWithParentA.field, fieldWithParentB.field, "they have different types")
return
}
- if (hasCatch && !areCatchesEqual(fieldA.directives.findCatches(schema), fieldB.directives.findCatches(schema))) {
+ if (hasCatch && !areCatchesEqual(fieldA.directives.findCatch(schema), fieldB.directives.findCatch(schema))) {
addFieldMergingIssue(fieldWithParentA.field, fieldWithParentB.field, "they have different `@catch` directives")
return
}
@@ -533,8 +564,8 @@ internal class ExecutableValidationScope(
}
}
- private fun areCatchesEqual(catchesA: List, catchesB: List): Boolean {
- return catchesA.toSet() == catchesB.toSet()
+ private fun areCatchesEqual(catchA: Catch?, catchesB: Catch?): Boolean {
+ return catchA == catchesB
}
private fun areArgumentsEqual(argumentsA: List, argumentsB: List): Boolean {
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/SchemaValidationScope.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/SchemaValidationScope.kt
index 1c0209993bb..8c7c46da0bc 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/SchemaValidationScope.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/SchemaValidationScope.kt
@@ -140,7 +140,7 @@ internal fun validateSchema(definitions: List, requiresApolloDefi
val existing = directiveDefinitions[definition.name]
if (existing != null) {
if (!existing.semanticEquals(definition)) {
- issues.add(IncompatibleDefinition(definition.name, definition.toSemanticSdl(), definition.sourceLocation))
+ issues.add(IncompatibleDefinition(definition.name, definition.toSemanticSdl(), existing.sourceLocation))
}
}
}
@@ -149,7 +149,7 @@ internal fun validateSchema(definitions: List, requiresApolloDefi
val existing = typeDefinitions[definition.name]
if (existing != null) {
if (!existing.semanticEquals(definition)) {
- issues.add(IncompatibleDefinition(definition.name, definition.toSemanticSdl(), definition.sourceLocation))
+ issues.add(IncompatibleDefinition(definition.name, definition.toSemanticSdl(), existing.sourceLocation))
}
}
}
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/definitions.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/definitions.kt
index 077b6cc3054..efd014086f3 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/definitions.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/definitions.kt
@@ -258,73 +258,117 @@ internal val linkDefinitionsStr = """
internal val nullabilityDefinitionsStr = """
""${'"'}
-Indicates that a field is only null if there is a matching error in the `errors` array.
-In all other cases, the field is non-null.
+Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.
+In all other cases, the position is non-null.
-Tools doing code generation may use this information to generate the field as non-null.
-
-This directive can be applied on field definitions:
+Tools doing code generation may use this information to generate the position as non-null if field errors are handled out of band:
```graphql
type User {
+ # email is semantically non-null and can be generated as non-null by error-handling clients.
email: String @semanticNonNull
}
```
-It can also be applied on object type extensions for use in client applications that do
-not own the base schema:
+The `levels` argument indicates what levels are semantically non null in case of lists:
```graphql
-extend type User @semanticNonNull(field: "email")
+type User {
+ # friends is semantically non null
+ friends: [User] @semanticNonNull # same as @semanticNonNull(levels: [0])
+
+ # every friends[k] is semantically non null
+ friends: [User] @semanticNonNull(levels: [1])
+
+ # friends as well as every friends[k] is semantically non null
+ friends: [User] @semanticNonNull(levels: [0, 1])
+}
```
-Control over list items is done using the `level` argument:
+`levels` are zero indexed.
+Passing a negative level or a level greater than the list dimension is an error.
+
+""${'"'}
+directive @semanticNonNull(levels: [Int] = [0]) on FIELD_DEFINITION
+
+""${'"'}
+Indicates that a position is semantically non null: it is only null if there is a matching error in the `errors` array.
+In all other cases, the position is non-null.
+
+`@semanticNonNullField` is the same as `@semanticNonNull` but can be used on type system extensions for services
+that do not own the schema like client services:
```graphql
-type User {
- # friends is nullable but friends[0] is null only on errors
- friends: [User] @semanticNonNull(level: 1)
-}
+# extend the schema to make User.email semantically non-null.
+extend type User @semanticNonNullField(name: "email")
```
-The `field` argument is the name of the field if `@semanticNonNull` is applied to an object definition.
-If `@semanticNonNull` is applied to a field definition, `field` must be null.
+The `levels` argument indicates what levels are semantically non null in case of lists:
-The `level` argument can be used to indicate what level is semantically non null in case of lists.
-`level` starts at 0 if there is no list. If `level` is null, all levels are semantically non null.
+```graphql
+# friends is semantically non null
+extend type User @semanticNonNullField(name: "friends") # same as @semanticNonNullField(name: "friends", levels: [0])
+
+# every friends[k] is semantically non null
+extend type User @semanticNonNullField(name: "friends", levels: [1])
+
+# friends as well as every friends[k] is semantically non null
+extend type User @semanticNonNullField(name: "friends", levels: [0, 1])
+```
+
+`levels` are zero indexed.
+Passing a negative level or a level greater than the list dimension is an error.
+
+See `@semanticNonNull`.
""${'"'}
-directive @semanticNonNull(field: String = null, level: Int = null) repeatable on FIELD_DEFINITION | OBJECT
+directive @semanticNonNullField(name: String!, levels: [Int] = [0]) repeatable on OBJECT | INTERFACE
""${'"'}
-Indicates that the given position stops GraphQL errors to propagate up the tree.
+Indicates how clients should handle errors on a given position.
-By default, the first GraphQL error stops the parsing and fails the whole response.
-Using `@catch` recovers from this error and allows the parsing to continue.
+When used on the schema definition, `@catch` applies to every position that can return an error.
-`@catch` must also be applied to the schema definition to specify the default to
-use for every field that can return an error (nullable fields).
+The `levels` argument indicates where to catch errors in case of lists:
+
+```graphql
+{
+ user {
+ # friends catches errors
+ friends @catch { name } # same as @catch(levels: [0])
+
+ # every friends[k] catches errors
+ friends @catch(levels: [0]) { name }
+
+ # friends as well as every friends[k] catches errors
+ friends @catch(levels: [0, 1]) { name }
+ }
+}
+```
-The `to` argument can be used to choose how to recover from errors. See `CatchTo`
-for more details.
+`levels` are zero indexed.
+Passing a negative level or a level greater than the list dimension is an error.
-The `level` argument can be used to indicate where to catch in case of lists.
-`level` starts at 0 if there is no list. If `level` is null, all levels catch.
+See `CatchTo` for more details.
""${'"'}
-directive @catch(to: CatchTo! = RESULT, level: Int = null) repeatable on FIELD | SCHEMA
+directive @catch(to: CatchTo! = RESULT, levels: [Int] = [0]) on FIELD | SCHEMA
enum CatchTo {
""${'"'}
- Map to a result type that can contain either a value or an error.
+ Catch the error and map the position to a result type that can contain either
+ a value or an error.
""${'"'}
RESULT,
""${'"'}
- Map to a nullable type that will be null in the case of error.
- This does not allow to distinguish between semantic null and error but
+ Catch the error and map the position to a nullable type that will be null
+ in the case of error.
+ This does not allow to distinguish between semantic null and error null but
can be simpler in some cases.
""${'"'}
NULL,
""${'"'}
- Do not catch and let any exception through
+ Throw the error.
+ Parent positions can recover using `RESULT` or `NULL`.
+ If no parent position recovers, the parsing stops.
""${'"'}
THROW
}
diff --git a/libraries/apollo-compiler/src/main/kotlin/com/apollographql/apollo3/compiler/ir/IrOperationsBuilder.kt b/libraries/apollo-compiler/src/main/kotlin/com/apollographql/apollo3/compiler/ir/IrOperationsBuilder.kt
index 4dfecd2b0c2..95fbe831e0f 100644
--- a/libraries/apollo-compiler/src/main/kotlin/com/apollographql/apollo3/compiler/ir/IrOperationsBuilder.kt
+++ b/libraries/apollo-compiler/src/main/kotlin/com/apollographql/apollo3/compiler/ir/IrOperationsBuilder.kt
@@ -34,7 +34,7 @@ import com.apollographql.apollo3.ast.TransformResult
import com.apollographql.apollo3.ast.VariableUsage
import com.apollographql.apollo3.ast.definitionFromScope
import com.apollographql.apollo3.ast.fieldDefinitions
-import com.apollographql.apollo3.ast.findCatches
+import com.apollographql.apollo3.ast.findCatch
import com.apollographql.apollo3.ast.findDeprecationReason
import com.apollographql.apollo3.ast.findNonnull
import com.apollographql.apollo3.ast.findOptInFeature
@@ -88,7 +88,7 @@ internal class IrOperationsBuilder(
else -> error("codegenModels='$codegenModels' is not supported")
}
- private val defaultCatch = schema.schemaDefinition?.directives?.findCatches(schema)?.singleOrNull()
+ private val defaultCatchTo = schema.schemaDefinition?.directives?.findCatch(schema)?.to
fun build(): IrOperations {
val operations = operationDefinitions.map { it.toIr() }
@@ -483,8 +483,8 @@ internal class IrOperationsBuilder(
val type: GQLType,
val deprecationReason: String?,
val optInFeature: String?,
- val semanticNonNulls: List,
- val catches: List,
+ val semanticNonNulls: List,
+ val catch: Catch?,
val forceOptional: Boolean,
/**
@@ -520,14 +520,14 @@ internal class IrOperationsBuilder(
if (parentTypeDefinition.isFieldNonNull(gqlField.name, schema)) {
check(semanticNonNulls.isEmpty()) {
- "${gqlField.sourceLocation}: field '${gqlField.responseName()}' already has nullability annotations (@nonnull, @semanticNonNull) in the schema."
+ "${gqlField.sourceLocation}: bad '@nonnull' directive: field '${gqlField.responseName()}' already has nullability annotations (@nonnull, @semanticNonNull) in the schema."
}
semanticNonNulls = listOf(0)
}
if (gqlField.directives.findNonnull(schema)) {
check(semanticNonNulls.isEmpty()) {
- "${gqlField.sourceLocation}: field '${gqlField.responseName()}' already has nullability annotations (@nonnull, @semanticNonNull) in the schema."
+ "${gqlField.sourceLocation}: bad '@nonnull' directive: field '${gqlField.responseName()}' already has nullability annotations (@nonnull, @semanticNonNull) in the schema."
}
semanticNonNulls = listOf(0)
}
@@ -544,7 +544,7 @@ internal class IrOperationsBuilder(
semanticNonNulls = semanticNonNulls,
forceOptional = gqlField.directives.optionalValue(schema) == true,
parentType = fieldWithParent.parentType,
- catches = gqlField.directives.findCatches(schema)
+ catch = gqlField.directives.findCatch(schema)
)
}.groupBy {
it.responseName
@@ -625,7 +625,7 @@ internal class IrOperationsBuilder(
}
}
// Finally, transform into Result or Nullable depending on catch
- .catch(first.catches, defaultCatch, 0)
+ .catch(first.catch, defaultCatchTo, 0)
/**
* Depending on the parent object/interface in which the field is queried, the field definition might have different descriptions/deprecationReasons
@@ -675,8 +675,8 @@ internal class IrOperationsBuilder(
}
companion object {
- private fun IrType.semanticNonNull(semanticNonNullLevels: List, level: Int): IrType {
- val isNonNull = semanticNonNullLevels.any { it == null || it == level }
+ private fun IrType.semanticNonNull(semanticNonNullLevels: List, level: Int): IrType {
+ val isNonNull = semanticNonNullLevels.any { it == level }
return when (this) {
is IrNamedType -> this
@@ -690,19 +690,23 @@ internal class IrOperationsBuilder(
}
}
- private fun IrType.catch(catchLevels: List, defaultCatch: Catch?, level: Int): IrType {
- var catchLevel = catchLevels.firstOrNull { it.level == null || it.level == level }
+ private fun IrType.catch(catch: Catch?, defaultCatchTo: CatchTo?, level: Int): IrType {
var type = when (this) {
is IrNamedType -> this
- is IrListType -> copy(ofType = ofType.catch(catchLevels, defaultCatch, level + 1))
+ is IrListType -> copy(ofType = ofType.catch(catch, defaultCatchTo, level + 1))
}
- if (catchLevel == null && type.maybeError) {
- catchLevel = defaultCatch
+ val catchTo = if (catch != null) {
+ catch.to.takeIf { catch.levels.contains(level) } ?: defaultCatchTo
+ } else if (type.maybeError) {
+ defaultCatchTo
+ } else {
+ null
}
- if (catchLevel != null) {
- type = type.catchTo(catchLevel.to.toIr())
- if (catchLevel.to == CatchTo.NULL) {
+
+ if (catchTo != null) {
+ type = type.catchTo(catchTo.toIr())
+ if (catchTo == CatchTo.NULL) {
type = type.nullable(true)
}
}
diff --git a/libraries/apollo-compiler/src/test/validation/operation/catch/catch.expected b/libraries/apollo-compiler/src/test/validation/operation/catch/catch.expected
index 10c6af4de40..b71ae52e91c 100644
--- a/libraries/apollo-compiler/src/test/validation/operation/catch/catch.expected
+++ b/libraries/apollo-compiler/src/test/validation/operation/catch/catch.expected
@@ -1,17 +1,17 @@
-OtherValidationIssue (2:5)
-`foo` cannot be merged with `foo` (at catch.graphql: (3, 5)): they have different `@catch` directives. Use different aliases on the fields to fetch both if this was intentional.
-------------
OtherValidationIssue (3:5)
-`foo` cannot be merged with `foo` (at catch.graphql: (2, 5)): they have different `@catch` directives. Use different aliases on the fields to fetch both if this was intentional.
+`foo` cannot be merged with `foo` (at catch.graphql: (4, 5)): they have different `@catch` directives. Use different aliases on the fields to fetch both if this was intentional.
+------------
+OtherValidationIssue (4:5)
+`foo` cannot be merged with `foo` (at catch.graphql: (3, 5)): they have different `@catch` directives. Use different aliases on the fields to fetch both if this was intentional.
------------
-OtherValidationIssue (7:9)
-Conflicting `@catch` usages for level 'null': a given list level must have a single CatchTo value
+OtherValidationIssue (9:9)
+Directive '@catch' cannot be repeated
------------
-OtherValidationIssue (11:9)
-Conflicting `@catch` usages for level '1': a given list level must have a single CatchTo value
+OtherValidationIssue (9:27)
+Directive '@catch' cannot be repeated
------------
-OtherValidationIssue (15:9)
-Conflicting `@catch` usages for level '1': this level conflicts with the default 'null' level
+OtherValidationIssue (14:25)
+'levels' values must be positive ints
------------
-OtherValidationIssue (19:9)
-Invalid 'level' value '3' for `@catch` usage: this type has a max list level of 2
\ No newline at end of file
+OtherValidationIssue (19:25)
+Invalid 'levels' value '3' for `@catch` usage: this field has a max list level of 2
\ No newline at end of file
diff --git a/libraries/apollo-compiler/src/test/validation/operation/catch/catch.graphql b/libraries/apollo-compiler/src/test/validation/operation/catch/catch.graphql
index fa2ea69b602..5964cd85fa3 100644
--- a/libraries/apollo-compiler/src/test/validation/operation/catch/catch.graphql
+++ b/libraries/apollo-compiler/src/test/validation/operation/catch/catch.graphql
@@ -1,25 +1,20 @@
+# `foo` cannot be merged with `foo` (at catch.graphql: (4, 5)): they have different `@catch` directives.
query Query1 {
foo
foo @catch(to: THROW)
}
+# Directive '@catch' cannot be repeated
query Query2 {
foo @catch(to: THROW) @catch(to: NULL)
}
+# 'levels' values must be positive ints
query Query3 {
- foo @catch(level: 1, to: THROW) @catch(level: 1, to: NULL)
+ foo @catch(levels: [-1], to: THROW)
}
+# Invalid 'levels' value '3' for `@catch` usage: this field has a max list level of 2
query Query4 {
- foo @catch(level: 1, to: THROW) @catch(to: NULL)
-}
-
-query Query5 {
- foo @catch(level: 3, to: THROW)
-}
-
-# OK
-query Query6 {
- foo @catch(level: 1, to: THROW) @catch(level: 2, to: NULL)
+ foo @catch(levels: [3], to: THROW)
}
diff --git a/libraries/apollo-compiler/src/test/validation/operation/catch/schema.graphqls b/libraries/apollo-compiler/src/test/validation/operation/catch/schema.graphqls
index 2a4e5896c36..ab531ee3cf2 100644
--- a/libraries/apollo-compiler/src/test/validation/operation/catch/schema.graphqls
+++ b/libraries/apollo-compiler/src/test/validation/operation/catch/schema.graphqls
@@ -1,4 +1,4 @@
-extend schema @link(url: "https://specs.apollo.dev/nullability/v0.1", import: ["@semanticNonNull", "@catch", "CatchTo"])
+extend schema @link(url: "https://specs.apollo.dev/nullability/v0.2", import: ["@semanticNonNull", "@catch", "CatchTo"])
extend schema @catch(to: THROW)
diff --git a/libraries/apollo-compiler/src/test/validation/schema/unexpected-definitions.expected b/libraries/apollo-compiler/src/test/validation/schema/unexpected-definitions.expected
index bc972f9c3ad..91d69d10c61 100644
--- a/libraries/apollo-compiler/src/test/validation/schema/unexpected-definitions.expected
+++ b/libraries/apollo-compiler/src/test/validation/schema/unexpected-definitions.expected
@@ -1,10 +1,10 @@
-IncompatibleDefinition (39:1)
-Unexpected 'catch' definition. Expecting 'directive @catch (to: CatchTo! = RESULT, level: Int = null) repeatable on FIELD|SCHEMA'.
+IncompatibleDefinition (2:1)
+Unexpected 'catch' definition. Expecting 'directive @catch (to: CatchTo! = RESULT, levels: [Int] = [0]) on FIELD|SCHEMA'.
------------
-IncompatibleDefinition (56:1)
+IncompatibleDefinition (3:1)
Unexpected 'CatchTo' definition. Expecting 'enum CatchTo { RESULT NULL THROW }'.
------------
-IncompatibleDefinition (73:1)
+IncompatibleDefinition (1:1)
Unexpected 'ignoreErrors' definition. Expecting 'directive @ignoreErrors on QUERY|MUTATION|SUBSCRIPTION'.
------------
OtherValidationIssue (null:null)
diff --git a/tests/catch/src/main/graphql/shared/levels.graphql b/tests/catch/src/main/graphql/shared/levels.graphql
new file mode 100644
index 00000000000..47de7572aa5
--- /dev/null
+++ b/tests/catch/src/main/graphql/shared/levels.graphql
@@ -0,0 +1,15 @@
+query List1 {
+ list1
+}
+
+query List {
+ list
+}
+
+query ListCatchAll {
+ list1 @catch(levels: [0, 1])
+}
+
+query ListCatch1 {
+ list1 @catch(levels: [1])
+}
diff --git a/tests/catch/src/main/graphql/shared/schema.graphqls b/tests/catch/src/main/graphql/shared/schema.graphqls
index 8a71fcb702d..2b9f995c685 100644
--- a/tests/catch/src/main/graphql/shared/schema.graphqls
+++ b/tests/catch/src/main/graphql/shared/schema.graphqls
@@ -1,20 +1,23 @@
-extend schema @link(url: "https://specs.apollo.dev/nullability/v0.1", import: ["@semanticNonNull", "@catch", "CatchTo", "@ignoreErrors"])
+extend schema @link(url: "https://specs.apollo.dev/nullability/v0.2", import: ["@semanticNonNullField", "@semanticNonNull", "@catch", "CatchTo", "@ignoreErrors"])
type Query {
nullable: Int
nonNull: Int!
semanticNonNull: Int @semanticNonNull
deep: [[[Int]]] @semanticNonNull
+ list: [Int]
+ list1: [Int] @semanticNonNull(levels: [1])
user: User @semanticNonNull
product: Product
}
+extend type Query @semanticNonNullField(name: "list", levels: [1])
+
type User {
name: String @semanticNonNull
friends: [User] @semanticNonNull
}
-
type Product {
price: String
}
\ No newline at end of file
diff --git a/tests/catch/src/test/kotlin/test/CatchThrowTest.kt b/tests/catch/src/test/kotlin/test/CatchThrowTest.kt
index a56ae8188d8..ccf56fb4d98 100644
--- a/tests/catch/src/test/kotlin/test/CatchThrowTest.kt
+++ b/tests/catch/src/test/kotlin/test/CatchThrowTest.kt
@@ -130,63 +130,3 @@ class CatchThrowTest {
assertNull(response.errors)
}
}
-
-private fun String.jsonReader(): JsonReader = Buffer().writeUtf8(this).jsonReader()
-
-fun Query.parseResponse(json: String): ApolloResponse = parseResponse(json.jsonReader(), null, CustomScalarAdapters.Empty, null)
-
-@Language("json")
-val userNameError = """
- {
- "errors": [
- {
- "path": ["user", "name"],
- "message": "cannot resolve name"
- }
- ],
- "data": {
- "user": {
- "name": null
- }
- }
- }
- """.trimIndent()
-
-@Language("json")
-val userSuccess = """
- {
- "data": {
- "user": {
- "name": "Pancakes"
- }
- }
- }
- """.trimIndent()
-
-@Language("json")
-val productPriceError = """
- {
- "errors": [
- {
- "path": ["product", "price"],
- "message": "cannot resolve price"
- }
- ],
- "data": {
- "product": {
- "price": null
- }
- }
- }
- """.trimIndent()
-
-@Language("json")
-val productPriceNull = """
- {
- "data": {
- "product": {
- "price": null
- }
- }
- }
- """.trimIndent()
\ No newline at end of file
diff --git a/tests/catch/src/test/kotlin/test/LevelsTest.kt b/tests/catch/src/test/kotlin/test/LevelsTest.kt
new file mode 100644
index 00000000000..c9502c4b35f
--- /dev/null
+++ b/tests/catch/src/test/kotlin/test/LevelsTest.kt
@@ -0,0 +1,87 @@
+package test
+
+import com.apollographql.apollo3.api.getOrThrow
+import com.apollographql.apollo3.api.graphQLErrorOrNull
+import junit.framework.TestCase.assertTrue
+import org.intellij.lang.annotations.Language
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class LevelsTest {
+ @Test
+ fun byDefaultListItemsAreNonNull() {
+ @Language("json")
+ val jsonResponse = """
+ {
+ "data": {
+ "list1": [0, 1]
+ }
+ }
+ """.trimIndent()
+
+ `throw`.List1Query().parseResponse(jsonResponse).apply {
+ // List items are non-null
+ assertTrue(data!!.list1!!.get(1).minus(1) == 0)
+ assertNull(exception)
+ }
+ }
+
+ @Test
+ fun canExtendSchemaTypes() {
+ @Language("json")
+ val jsonResponse = """
+ {
+ "data": {
+ "list": [0, 1]
+ }
+ }
+ """.trimIndent()
+
+ `throw`.ListQuery().parseResponse(jsonResponse).apply {
+ // List items are non-null
+ assertTrue(data!!.list!!.get(1).minus(1) == 0)
+ assertNull(exception)
+ }
+ }
+
+ @Test
+ fun canCatchItem() {
+ @Language("json")
+ val jsonResponse = """
+ {
+ "errors": [
+ { "path": ["list1", 1], "message": "item error" }
+ ],
+ "data": {
+ "list1": [0, null]
+ }
+ }
+ """.trimIndent()
+
+ `throw`.ListCatchAllQuery().parseResponse(jsonResponse).apply {
+ assertEquals("item error", data!!.list1.getOrThrow()?.get(1)?.graphQLErrorOrNull()?.message)
+ assertNull(exception)
+ }
+ }
+
+ @Test
+ fun canCatchList() {
+ @Language("json")
+ val jsonResponse = """
+ {
+ "errors": [
+ { "path": ["list1"], "message": "list error" }
+ ],
+ "data": {
+ "list1": null
+ }
+ }
+ """.trimIndent()
+
+ `throw`.ListCatchAllQuery().parseResponse(jsonResponse).apply {
+ assertEquals("list error", data!!.list1.graphQLErrorOrNull()?.message)
+ assertNull(exception)
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/catch/src/test/kotlin/test/common.kt b/tests/catch/src/test/kotlin/test/common.kt
new file mode 100644
index 00000000000..16b034dddb7
--- /dev/null
+++ b/tests/catch/src/test/kotlin/test/common.kt
@@ -0,0 +1,72 @@
+package test
+
+import com.apollographql.apollo3.api.ApolloResponse
+import com.apollographql.apollo3.api.CustomScalarAdapters
+import com.apollographql.apollo3.api.Query
+import com.apollographql.apollo3.api.json.JsonReader
+import com.apollographql.apollo3.api.json.jsonReader
+import com.apollographql.apollo3.api.parseResponse
+import okio.Buffer
+import org.intellij.lang.annotations.Language
+
+
+private fun String.jsonReader(): JsonReader = Buffer().writeUtf8(this).jsonReader()
+
+fun Query.parseResponse(json: String): ApolloResponse = parseResponse(json.jsonReader(), null, CustomScalarAdapters.Empty, null)
+
+@Language("json")
+val userNameError = """
+ {
+ "errors": [
+ {
+ "path": ["user", "name"],
+ "message": "cannot resolve name"
+ }
+ ],
+ "data": {
+ "user": {
+ "name": null
+ }
+ }
+ }
+ """.trimIndent()
+
+@Language("json")
+val userSuccess = """
+ {
+ "data": {
+ "user": {
+ "name": "Pancakes"
+ }
+ }
+ }
+ """.trimIndent()
+
+@Language("json")
+val productPriceError = """
+ {
+ "errors": [
+ {
+ "path": ["product", "price"],
+ "message": "cannot resolve price"
+ }
+ ],
+ "data": {
+ "product": {
+ "price": null
+ }
+ }
+ }
+ """.trimIndent()
+
+@Language("json")
+val productPriceNull = """
+ {
+ "data": {
+ "product": {
+ "price": null
+ }
+ }
+ }
+ """.trimIndent()
+