Skip to content

Commit

Permalink
[⚒️compiler] Track schemanticNonNull spec (#5577)
Browse files Browse the repository at this point in the history
* [ast] validate repeatable directives

* relax the validation

* Update nullability directives to 0.2

* revert doc changes

* update test fixtures
  • Loading branch information
martinbonnin authored Jan 29, 2024
1 parent f9bd5e1 commit dc34c4b
Show file tree
Hide file tree
Showing 24 changed files with 438 additions and 219 deletions.
8 changes: 7 additions & 1 deletion .idea/runConfigurations/ValidationTest.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
<p>
Before being referenced, directives and types supported by Apollo Kotlin must be imported by your schema using the <code>@link</code> directive<br>.
For instance, to use the <code>@semanticNonNull</code> directive, import it from the
<a href="https://specs.apollo.dev/nullability/v0.1"><code>nullability</code></a> definitions:
<a href="https://specs.apollo.dev/nullability/v0.2"><code>nullability</code></a> definitions:
<pre>
extend schema
@link(
url: "https://specs.apollo.dev/nullability/v0.1",
url: "https://specs.apollo.dev/nullability/v0.2",
import: ["@semanticNonNull"]
)
</pre>
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]
)
Original file line number Diff line number Diff line change
@@ -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"]
)
Original file line number Diff line number Diff line change
@@ -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"]
)
Original file line number Diff line number Diff line change
@@ -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"]
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
)

Expand Down
1 change: 1 addition & 0 deletions libraries/apollo-ast/api/apollo-ast.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>)

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) {
Expand All @@ -99,6 +99,18 @@ fun GQLDirective.getArgument(argName: String, schema: Schema): GQLValue? {
return argument.value
}

private fun GQLValue.toListOfInt(): List<Int> {
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
Expand Down Expand Up @@ -136,32 +148,39 @@ private fun GQLValue?.toCatchTo(): CatchTo {
}

@ApolloInternal
fun List<GQLDirective>.findCatches(schema: Schema): List<Catch> {
fun List<GQLDirective>.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<Int?> {
return directives.filter {
fun GQLFieldDefinition.findSemanticNonNulls(schema: Schema): List<Int> {
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<Int?> {
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<Int> {
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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ fun kotlinLabsDefinitions(version: String): List<GQLDefinition> {
})
}

@ApolloInternal const val NULLABILITY_VERSION = "v0.1"
@ApolloInternal const val NULLABILITY_VERSION = "v0.2"

/**
* Extra nullability definitions from https://specs.apollo.dev/nullability/<[version]>
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
)
}
}
}
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -533,8 +564,8 @@ internal class ExecutableValidationScope(
}
}

private fun areCatchesEqual(catchesA: List<Catch>, catchesB: List<Catch>): Boolean {
return catchesA.toSet() == catchesB.toSet()
private fun areCatchesEqual(catchA: Catch?, catchesB: Catch?): Boolean {
return catchA == catchesB
}

private fun areArgumentsEqual(argumentsA: List<GQLArgument>, argumentsB: List<GQLArgument>): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ internal fun validateSchema(definitions: List<GQLDefinition>, 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))
}
}
}
Expand All @@ -149,7 +149,7 @@ internal fun validateSchema(definitions: List<GQLDefinition>, 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))
}
}
}
Expand Down
Loading

0 comments on commit dc34c4b

Please sign in to comment.