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 @@ false true false + 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:

         extend schema
         @link(
-          url: "https://specs.apollo.dev/nullability/v0.1",
+          url: "https://specs.apollo.dev/nullability/v0.2",
           import: ["@semanticNonNull"]
         )
     
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() +