Repository: apollographql/apollo-kotlin
Pull request #5118 (2023-07-24 10:37:47)

Initial CCN support
Initial CCN support. APIs are under a `@ApolloExperimental` flag for now so we can change if needed but the codegen uses it by default. I'm not super keen on adding a configuration option for that. I'll dig into the different versioning options that exist in GraphQL

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *apollo-android-docs* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 561857370fedf16165cb2e339134de91c1bf476e |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/apollo-android-docs/deploys/64be547e33bec300086308dc |

Patch:
diff --git a/build-logic/src/main/kotlin/Common.kt b/build-logic/src/main/kotlin/Common.kt
new file mode 100644
index 0000000000..c4dd569149
--- /dev/null
+++ b/build-logic/src/main/kotlin/Common.kt
@@ -0,0 +1,22 @@
+
+import org.gradle.api.Project
+import org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest
+
+fun Project.commonSetup() {
+  pluginManager.withPlugin("org.jetbrains.kotlin.jvm") {
+    tasks.register("ft") {
+      if (this@commonSetup.name != "apollo-gradle-plugin") {
+        dependsOn("test")
+      }
+    }
+  }
+  pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
+    val fastTest = tasks.register("ft")
+
+    tasks.withType(KotlinJvmTest::class.java) {
+      fastTest.configure {
+        this.dependsOn(this@withType)
+      }
+    }
+  }
+}
diff --git a/build-logic/src/main/kotlin/CompilerOptions.kt b/build-logic/src/main/kotlin/CompilerOptions.kt
index f80c00c910..34d607d1d0 100644
--- a/build-logic/src/main/kotlin/CompilerOptions.kt
+++ b/build-logic/src/main/kotlin/CompilerOptions.kt
@@ -6,7 +6,6 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion
 import org.gradle.jvm.toolchain.JavaToolchainService
 import org.jetbrains.kotlin.gradle.dsl.KotlinCompile
 import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions
-import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion
 import org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile
 
 fun Project.configureJavaAndKotlinCompilers() {
diff --git a/build-logic/src/main/kotlin/com/apollographql/apollo3/buildlogic/plugin/LibraryConventionPlugin.kt b/build-logic/src/main/kotlin/com/apollographql/apollo3/buildlogic/plugin/LibraryConventionPlugin.kt
index 1030b85fac..b730599fbf 100644
--- a/build-logic/src/main/kotlin/com/apollographql/apollo3/buildlogic/plugin/LibraryConventionPlugin.kt
+++ b/build-logic/src/main/kotlin/com/apollographql/apollo3/buildlogic/plugin/LibraryConventionPlugin.kt
@@ -1,5 +1,6 @@
 package com.apollographql.apollo3.buildlogic.plugin
 
+import commonSetup
 import configureJavaAndKotlinCompilers
 import configureMppDefaults
 import configurePublishing
@@ -19,6 +20,7 @@ class LibraryConventionPlugin : Plugin<Project> {
       group = property("GROUP")!!
       version = property("VERSION_NAME")!!
 
+      commonSetup()
       extensions.create("apolloLibrary", Extension::class.java, project)
 
       configureJavaAndKotlinCompilers()
diff --git a/docs/source/advanced/java.mdx b/docs/source/advanced/java.mdx
index ebd34978d0..db75ee52af 100644
--- a/docs/source/advanced/java.mdx
+++ b/docs/source/advanced/java.mdx
@@ -16,33 +16,61 @@ apollo {
 }
 ```
 
-## Build the client
+## The Java runtime
 
-This snippet demonstrates initializing an `ApolloClient` instance in Java:
+The default runtime for Apollo Kotlin, `apollo-runtime`, exposes a coroutines / Flow-based API that isn't well suited to be consumed from Java.
+That is why a specific runtime, `apollo-runtime-java` is available to use Apollo Kotlin from Java. To use it, add a dependency on this runtime instead of the default one:
+
+```kotlin title="build.gradle[.kts]"
+dependencies {
+  // ...
+
+  // Use apollo-runtime-java instead of apollo-runtime
+  implementation("com.apollographql.apollo3:apollo-runtime-java:4.0.0-alpha.2")
+}
+```
+
+The Java runtime has a callbacks based API. This snippet demonstrates initializing and using an `ApolloClient` in Java:
 
 ```java
-import com.apollographql.apollo3.cache.normalized.NormalizedCache;
-import com.apollographql.apollo3.cache.http.HttpCache;
+import com.apollographql.apollo3.runtime.java.ApolloClient;
 // (...)
 
-ApolloClient.Builder builder = new ApolloClient.Builder()
+ApolloClient client = ApolloClient.Builder builder = new ApolloClient.Builder()
   .serverUrl("http://localhost:4000/graphql")
+  .build();
 
-// Optionally, set an http cache
-HttpCache.configureApolloClientBuilder(builder, cacheDirectory, cacheMaxSize);
+// Use enqueue to execute a query asynchronously
+apolloClient.query(new GetRandomQuery()).enqueue(response -> {
+  if (response.data != null) {
+    // No errors
+    System.out.println(response.data);
+  } else {
+    // Errors
+    if (response.exception instanceof ApolloGraphQLException) {
+      // GraphQL errors
+      System.out.println(((ApolloGraphQLException) response.exception).getErrors().get(0));
+    } else {
+      // Network error
+      response.exception.printStackTrace();
+    }
+  }
+});
+```
+
+Note that as of now, the Java runtime doesn't support the Http or Normalized caches.
 
-// Optionally, set a normalized cache
-NormalizedCache.configureApolloClientBuilder(
-  builder,
-  new MemoryCacheFactory(10 * 1024 * 1024, -1),
-  TypePolicyCacheKeyGenerator.INSTANCE,
-  FieldPolicyCacheResolver.INSTANCE,
-  false
-);
+### Cancelling requests
+
+`euqueue` returns an `ApolloDisposable` that can be used to cancel the request:
+
+```java
+ApolloDisposable disposable = apolloClient.subscription(new MyQuery()).enqueue(response -> ...)
+// ...
+disposable.dispose();
 
-ApolloClient client = builder.build();
 ```
 
-## Use RxJava extensions
+### RxJava extensions
 
-Apollo Kotlin has a coroutines / Flow-based API that isn't well suited to using with Java. To achieve a similar effect, you can use Apollo's [RxJava extensions](./rxjava/).
+If your project uses RxJava, you can use Apollo's [RxJava extensions](./rxjava/) with the Java runtime.
diff --git a/docs/source/advanced/rxjava.mdx b/docs/source/advanced/rxjava.mdx
index 2ceaf7d1bc..56d4ccbb3e 100644
--- a/docs/source/advanced/rxjava.mdx
+++ b/docs/source/advanced/rxjava.mdx
@@ -2,9 +2,23 @@
 title: RxJava support
 ---
 
-If you're using Apollo Kotlin in a [Java project](java) or a Kotlin project that uses RxJava, you can use Apollo's RxJava extensions.
+If you're using Apollo Kotlin in a [Java project](java) or Kotlin project that uses RxJava, you can use Apollo's RxJava extensions.
 
-To do so, add the `apollo-rx2-support` or `apollo-rx3-support` dependency to your project:
+To do so, add the `apollo-rx2-support-java` / `apollo-rx3-support-java` (Java) or `apollo-rx2-support` / `apollo-rx3-support` (Kotlin) dependency to your project:
+
+<MultiCodeBlock>
+
+```java title="build.gradle[.kts]"
+dependencies {
+  // ...
+
+  // For RxJava 2
+  implementation("com.apollographql.apollo3:apollo-rx2-support-java:4.0.0-alpha.2")
+
+  // For RxJava 3
+  implementation("com.apollographql.apollo3:apollo-rx3-support-java:4.0.0-alpha.2")
+}
+```
 
 ```kotlin title="build.gradle[.kts]"
 dependencies {
@@ -18,30 +32,35 @@ dependencies {
 }
 ```
 
+</MultiCodeBlock>
+
+
 ## Executing operations
 
-Use the `rxSingle` / `rxFlowable` extensions to execute GraphQL operations and get RxJava observables:
+In Java, Use the `Rx2Apollo` or `Rx3Apollo` classes to execute GraphQL operations and get RxJava observables.
+
+In Kotlin, use the `rxSingle()` / `rxFlowable()` extensions.
 
 <MultiCodeBlock>
 
 ```java
-import com.apollographql.apollo3.rx2.Rx2Apollo;
+import com.apollographql.apollo3.rx3.java.Rx3Apollo;
 
 // (...)
 
 // Query
 ApolloCall<MyQuery.Data> queryCall = client.query(new MyQuery());
-Single<ApolloResponse<MyQuery.Data>> queryResponse = Rx2Apollo.single(queryCall);
+Single<ApolloResponse<MyQuery.Data>> queryResponse = Rx3Apollo.single(queryCall);
 queryResponse.subscribe( /* ... */ );
 
 // Mutation
 ApolloCall<MyMutation.Data> mutationCall = client.mutation(new MyMutation("my-parameter"));
-Single<ApolloResponse<MyMutation.Data>> mutationResponse = Rx2Apollo.single(mutationCall);
+Single<ApolloResponse<MyMutation.Data>> mutationResponse = Rx3Apollo.single(mutationCall);
 mutationResponse.subscribe( /* ... */ );
 
 // Subscription
 ApolloCall<MySubscription.Data> subscriptionCall = client.subscription(new MySubscription());
-Flowable<ApolloResponse<MySubscription.Data>> subscriptionResponse = Rx2Apollo.flowable(subscriptionCall);
+Flowable<ApolloResponse<MySubscription.Data>> subscriptionResponse = Rx3Apollo.flowable(subscriptionCall);
 subscriptionResponse.subscribe( /* ... */ );
 ```
 
diff --git a/intellij-plugin/build.gradle.kts b/intellij-plugin/build.gradle.kts
index df14323ab3..e247925716 100644
--- a/intellij-plugin/build.gradle.kts
+++ b/intellij-plugin/build.gradle.kts
@@ -12,6 +12,8 @@ plugins {
   id("maven-publish")
 }
 
+commonSetup()
+
 // XXX: this should use the settings repositories instead
 repositories {
   // Uncomment this one to use the Kotlin "dev" repository
diff --git a/libraries/apollo-ast/api/apollo-ast.api b/libraries/apollo-ast/api/apollo-ast.api
index 1f74d6dc6a..a39b9bdba8 100644
--- a/libraries/apollo-ast/api/apollo-ast.api
+++ b/libraries/apollo-ast/api/apollo-ast.api
@@ -845,12 +845,28 @@ public abstract interface class com/apollographql/apollo3/ast/NodeTransformer {
 
 public final class com/apollographql/apollo3/ast/ParserOptions {
 	public static final field Companion Lcom/apollographql/apollo3/ast/ParserOptions$Companion;
+	public synthetic fun <init> (ZZZZLkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public final fun getAllowClientControlledNullability ()Z
+	public final fun getAllowEmptyDocuments ()Z
+	public final fun getUseAntlr ()Z
+	public final fun getWithSourceLocation ()Z
+}
+
+public final class com/apollographql/apollo3/ast/ParserOptions$Builder {
 	public fun <init> ()V
-	public fun <init> (ZZZ)V
-	public synthetic fun <init> (ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public final fun allowClientControlledNullability (Z)Lcom/apollographql/apollo3/ast/ParserOptions$Builder;
+	public final fun allowEmptyDocuments (Z)Lcom/apollographql/apollo3/ast/ParserOptions$Builder;
+	public final fun build ()Lcom/apollographql/apollo3/ast/ParserOptions;
+	public final fun getAllowClientControlledNullability ()Z
 	public final fun getAllowEmptyDocuments ()Z
 	public final fun getUseAntlr ()Z
 	public final fun getWithSourceLocation ()Z
+	public final fun setAllowClientControlledNullability (Z)V
+	public final fun setAllowEmptyDocuments (Z)V
+	public final fun setUseAntlr (Z)V
+	public final fun setWithSourceLocation (Z)V
+	public final fun useAntlr (Z)Lcom/apollographql/apollo3/ast/ParserOptions$Builder;
+	public final fun withSourceLocation (Z)Lcom/apollographql/apollo3/ast/ParserOptions$Builder;
 }
 
 public final class com/apollographql/apollo3/ast/ParserOptions$Companion {
diff --git a/libraries/apollo-ast/build.gradle.kts b/libraries/apollo-ast/build.gradle.kts
index 3b5d9ca8ad..321baef6fa 100644
--- a/libraries/apollo-ast/build.gradle.kts
+++ b/libraries/apollo-ast/build.gradle.kts
@@ -37,6 +37,11 @@ kotlin {
         implementation(libs.antlr.runtime)
       }
     }
+    getByName("jvmTest") {
+      dependencies {
+        implementation(libs.google.testparameterinjector)
+      }
+    }
   }
 }
 
@@ -44,6 +49,12 @@ dependencies {
   antlr(libs.antlr)
 }
 
+tasks.named("jvmTest") {
+  inputs.dir("test-fixtures/parser")
+      .withPropertyName("testFixtures")
+      .withPathSensitivity(PathSensitivity.RELATIVE)
+}
+
 // Only expose the antlr runtime dependency
 // See https://github.com/gradle/gradle/issues/820#issuecomment-288838412
 configurations["jvmMainApi"].apply {
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/api.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/api.kt
index e34d0549d3..8b0ef2f61e 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/api.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/api.kt
@@ -44,13 +44,13 @@ fun BufferedSource.toExecutableDefinitions(
     .validateAsExecutable(schema, fieldsOnDisjointTypesMustMerge)
     .getOrThrow()
 
-private fun <T : Any> BufferedSource.parseInternal(filePath: String?, withSourceLocation: Boolean, block: Parser.() -> T): GQLResult<T> {
-  return this.use { readUtf8() }.parseInternal(filePath, withSourceLocation, block)
+private fun <T : Any> BufferedSource.parseInternal(filePath: String?, options: ParserOptions, block: Parser.() -> T): GQLResult<T> {
+  return this.use { readUtf8() }.parseInternal(filePath, options, block)
 }
 
-private fun <T : Any> String.parseInternal(filePath: String?, withSourceLocation: Boolean, block: Parser.() -> T): GQLResult<T> {
+private fun <T : Any> String.parseInternal(filePath: String?, options: ParserOptions, block: Parser.() -> T): GQLResult<T> {
   return try {
-    GQLResult(Parser(this, withSourceLocation, filePath).block(), emptyList())
+    GQLResult(Parser(this, options, filePath).block(), emptyList())
   } catch (e: ParserException) {
     GQLResult(
         null,
@@ -86,15 +86,48 @@ private fun <T : Any> String.parseInternal(filePath: String?, withSourceLocation
   }
 }
 
-class ParserOptions(
+class ParserOptions private constructor(
     @Deprecated("This is used as a fallback the time to stabilize the new parser but will be removed")
     @ApolloDeprecatedSince(ApolloDeprecatedSince.Version.v4_0_0)
-    val useAntlr: Boolean = false,
-    val allowEmptyDocuments: Boolean = true,
-    val withSourceLocation: Boolean = true,
+    val useAntlr: Boolean,
+    val allowEmptyDocuments: Boolean,
+    val allowClientControlledNullability: Boolean,
+    val withSourceLocation: Boolean,
 ) {
+  class Builder {
+    var useAntlr: Boolean = false
+    var allowEmptyDocuments = true
+    var allowClientControlledNullability = true
+    var withSourceLocation = true
+
+    fun useAntlr(useAntlr: Boolean) = apply {
+      this.useAntlr = useAntlr
+    }
+
+    fun allowEmptyDocuments(allowEmptyDocuments: Boolean) = apply {
+      this.allowEmptyDocuments = allowEmptyDocuments
+    }
+
+    fun allowClientControlledNullability(allowClientControlledNullability: Boolean) = apply {
+      this.allowClientControlledNullability = allowClientControlledNullability
+    }
+
+    fun withSourceLocation(withSourceLocation: Boolean) = apply {
+      this.withSourceLocation = withSourceLocation
+    }
+
+    fun build(): ParserOptions {
+      return ParserOptions(
+          useAntlr = useAntlr,
+          allowEmptyDocuments = allowEmptyDocuments,
+          allowClientControlledNullability = allowClientControlledNullability,
+          withSourceLocation = withSourceLocation
+      )
+    }
+  }
+
   companion object {
-    val Default = ParserOptions()
+    val Default = Builder().build()
   }
 }
 
@@ -108,31 +141,34 @@ fun String.parseAsGQLDocument(options: ParserOptions = ParserOptions.Default): G
   return if (options.useAntlr) {
     Buffer().writeUtf8(this).parseAsGQLDocument(options = options)
   } else {
-    parseInternal(null, options.withSourceLocation) { parseDocument(options.allowEmptyDocuments) }
+    parseInternal(null, options) { parseDocument() }
   }
 }
+
 fun String.parseAsGQLValue(options: ParserOptions = ParserOptions.Default): GQLResult<GQLValue> {
   @Suppress("DEPRECATION")
   return if (options.useAntlr) {
     Buffer().writeUtf8(this).parseAsGQLValue(options = options)
   } else {
-    parseInternal(null, options.withSourceLocation) { parseValue() }
+    parseInternal(null, options) { parseValue() }
   }
 }
+
 fun String.parseAsGQLType(options: ParserOptions = ParserOptions.Default): GQLResult<GQLType> {
   @Suppress("DEPRECATION")
   return if (options.useAntlr) {
     Buffer().writeUtf8(this).parseAsGQLType(options = options)
   } else {
-    parseInternal(null, options.withSourceLocation) { parseType() }
+    parseInternal(null, options) { parseType() }
   }
 }
+
 fun String.parseAsGQLSelections(options: ParserOptions = ParserOptions.Default): GQLResult<List<GQLSelection>> {
   @Suppress("DEPRECATION")
   return if (options.useAntlr) {
     Buffer().writeUtf8(this).parseAsGQLSelections(options = options)
   } else {
-    parseInternal(null, options.withSourceLocation) { parseSelections() }
+    parseInternal(null, options) { parseSelections() }
   }
 }
 
@@ -156,7 +192,7 @@ fun BufferedSource.parseAsGQLDocument(filePath: String? = null, options: ParserO
   return if (options.useAntlr) {
     parseDocumentWithAntlr(this, filePath)
   } else {
-    parseInternal(filePath, options.withSourceLocation) { parseDocument(options.allowEmptyDocuments) }
+    parseInternal(filePath, options) { parseDocument() }
   }
 }
 
@@ -171,7 +207,7 @@ fun BufferedSource.parseAsGQLValue(filePath: String? = null, options: ParserOpti
   return if (options.useAntlr) {
     parseValueWithAntlr(this, filePath)
   } else {
-    parseInternal(filePath, options.withSourceLocation) { parseValue() }
+    parseInternal(filePath, options) { parseValue() }
   }
 }
 
@@ -186,7 +222,7 @@ fun BufferedSource.parseAsGQLType(filePath: String? = null, options: ParserOptio
   return if (options.useAntlr) {
     parseTypeWithAntlr(this, filePath)
   } else {
-    parseInternal(filePath, options.withSourceLocation) { parseType() }
+    parseInternal(filePath, options) { parseType() }
   }
 }
 
@@ -204,7 +240,7 @@ fun BufferedSource.parseAsGQLSelections(
   return if (options.useAntlr) {
     parseSelectionsWithAntlr(this, filePath)
   } else {
-    parseInternal(filePath, options.withSourceLocation) { parseSelections() }
+    parseInternal(filePath, options) { parseSelections() }
   }
 }
 
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gql.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gql.kt
index 6bd7354f7d..c711eaa686 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gql.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gql.kt
@@ -1,6 +1,7 @@
 package com.apollographql.apollo3.ast
 
 import com.apollographql.apollo3.annotations.ApolloDeprecatedSince
+import com.apollographql.apollo3.annotations.ApolloExperimental
 
 /**
  * The GraphQL AST definition
@@ -131,7 +132,7 @@ class GQLDocument(
     val definitions: List<GQLDefinition>,
     override val sourceLocation: SourceLocation?,
 ) : GQLNode {
-  constructor(definitions: List<GQLDefinition>, filePath: String?): this(definitions, SourceLocation.forPath(filePath))
+  constructor(definitions: List<GQLDefinition>, filePath: String?) : this(definitions, SourceLocation.forPath(filePath))
 
   override val children = definitions
 
@@ -1493,14 +1494,94 @@ private fun List<GQLArgument>.writeArguments(writer: SDLWriter) {
   join(writer, prefix = "(", separator = ", ", postfix = ")")
 }
 
-class GQLField(
+
+@ApolloExperimental
+sealed interface GQLNullability : GQLNode
+
+@ApolloExperimental
+class GQLNonNullDesignator(override val sourceLocation: SourceLocation? = null) : GQLNullability {
+  override val children: List<GQLNode>
+    get() = emptyList()
+
+  override fun writeInternal(writer: SDLWriter) {
+    writer.write("!")
+  }
+
+  override fun copyWithNewChildrenInternal(container: NodeContainer): GQLNode {
+    return this
+  }
+}
+
+@ApolloExperimental
+class GQLNullDesignator(override val sourceLocation: SourceLocation? = null) : GQLNullability {
+  override val children: List<GQLNode>
+    get() = emptyList()
+
+  override fun writeInternal(writer: SDLWriter) {
+    writer.write("?")
+  }
+
+  override fun copyWithNewChildrenInternal(container: NodeContainer): GQLNode {
+    return this
+  }
+}
+
+@ApolloExperimental
+class GQLListNullability(
+    override val sourceLocation: SourceLocation? = null,
+    val itemNullability: GQLNullability,
+    val selfNullability: GQLNullability?,
+) : GQLNullability {
+  override val children: List<GQLNode>
+    get() = listOf(itemNullability)
+
+  override fun writeInternal(writer: SDLWriter) {
+    writer.write("[")
+    writer.write(itemNullability)
+    writer.write("]")
+    if (selfNullability != null) {
+      writer.write(selfNullability)
+    }
+  }
+
+  override fun copyWithNewChildrenInternal(container: NodeContainer): GQLNode {
+    return copy(
+        ofNullability = container.takeSingle()!!
+    )
+  }
+
+  fun copy(
+      sourceLocation: SourceLocation? = this.sourceLocation,
+      ofNullability: GQLNullability = this.itemNullability,
+      selfNullability: GQLNullability? = this.selfNullability,
+  ): GQLListNullability {
+    return GQLListNullability(
+        sourceLocation,
+        ofNullability,
+        selfNullability
+    )
+  }
+}
+
+class GQLField @ApolloExperimental constructor(
     override val sourceLocation: SourceLocation? = null,
     val alias: String?,
     val name: String,
     val arguments: List<GQLArgument>,
     val directives: List<GQLDirective>,
     val selections: List<GQLSelection>,
+    @property:ApolloExperimental
+    val nullability: GQLNullability?,
 ) : GQLSelection() {
+  constructor(
+      sourceLocation: SourceLocation? = null,
+      alias: String?,
+      name: String,
+      arguments: List<GQLArgument>,
+      directives: List<GQLDirective>,
+      selections: List<GQLSelection>,
+  ) : this(sourceLocation, alias, name, arguments, directives, selections, null)
+
   @Suppress("DEPRECATION")
   @Deprecated("Use selections directly")
   @ApolloDeprecatedSince(ApolloDeprecatedSince.Version.v4_0_0)
@@ -1527,6 +1608,9 @@ class GQLField(
         write(" ")
         directives.join(writer)
       }
+      if (nullability != null) {
+        writer.write(nullability)
+      }
       if (selections.isNotEmpty()) {
         write(" ")
         selections.writeSelections(writer)
@@ -1550,6 +1634,23 @@ class GQLField(
       arguments = arguments,
       directives = directives,
       selections = selections,
+      nullability = this.nullability
+  )
+
+  /**
+   * This is in a separate method from the copy() above so that we can more easily remove it if we need to
+   */
+  @ApolloExperimental
+  fun copy(
+      nullability: GQLNullability?,
+  ) = GQLField(
+      sourceLocation = sourceLocation,
+      alias = alias,
+      name = name,
+      arguments = arguments,
+      directives = directives,
+      selections = selections,
+      nullability = nullability
   )
 
   override fun copyWithNewChildrenInternal(container: NodeContainer): GQLNode {
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqltype.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqltype.kt
index 2508a3a679..563a590e56 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqltype.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/gqltype.kt
@@ -1,5 +1,7 @@
 package com.apollographql.apollo3.ast
 
+import com.apollographql.apollo3.annotations.ApolloExperimental
+
 @Deprecated("Use rawType instead", ReplaceWith("rawType()"))
 fun GQLType.leafType(): GQLNamedType = rawType()
 
@@ -81,3 +83,45 @@ internal fun GQLType.isOutputType(typeDefinitions: Map<String, GQLTypeDefinition
         || it is GQLEnumTypeDefinition
   }
 }
+
+private fun GQLNullability?.selfNullability(): GQLNullability? {
+  return when (this) {
+    is GQLListNullability -> this.selfNullability
+    else -> this
+  }
+}
+
+private fun GQLType.withListNullability(nullability: GQLNullability?): GQLType {
+  if (this is GQLListType && nullability is GQLListNullability) {
+    return copy(type = type.withNullability(nullability.itemNullability))
+  } else if (this is GQLListType && nullability !is GQLListNullability) {
+    return this
+  } else if (this !is GQLListType && nullability is GQLListNullability) {
+    return this
+  } else if (this !is GQLListType && nullability !is GQLListNullability) {
+    return this
+  } else {
+    error("")
+  }
+}
+
+@ApolloExperimental
+fun GQLType.withNullability(nullability: GQLNullability?): GQLType {
+  val selfNullability = nullability.selfNullability()
+
+  if (this is GQLNonNullType && selfNullability == null) {
+    return this.copy(type = type.withListNullability(nullability))
+  } else if (this is GQLNonNullType && selfNullability is GQLNonNullDesignator) {
+    return this.copy(type = type.withListNullability(nullability))
+  } else if (this is GQLNonNullType && selfNullability is GQLNullDesignator) {
+    return this.type.withListNullability(nullability)
+  } else if (this !is GQLNonNullType && selfNullability == null) {
+    return this.withListNullability(nullability)
+  } else if (this !is GQLNonNullType && selfNullability is GQLNonNullDesignator) {
+    return GQLNonNullType(type = this.withListNullability(nullability))
+  } else if (this !is GQLNonNullType && selfNullability is GQLNullDesignator) {
+    return this.withListNullability(nullability)
+  } else {
+    error("")
+  }
+}
\ No newline at end of file
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 253c0b20f1..776ac1511d 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
@@ -12,15 +12,19 @@ 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.GQLListNullability
 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.GQLNullability
 import com.apollographql.apollo3.ast.GQLObjectTypeDefinition
 import com.apollographql.apollo3.ast.GQLObjectValue
 import com.apollographql.apollo3.ast.GQLOperationDefinition
+import com.apollographql.apollo3.ast.GQLNullDesignator
+import com.apollographql.apollo3.ast.GQLNonNullDesignator
 import com.apollographql.apollo3.ast.GQLScalarTypeDefinition
 import com.apollographql.apollo3.ast.GQLSelection
 import com.apollographql.apollo3.ast.GQLStringValue
@@ -40,6 +44,7 @@ import com.apollographql.apollo3.ast.rawType
 import com.apollographql.apollo3.ast.responseName
 import com.apollographql.apollo3.ast.rootTypeDefinition
 import com.apollographql.apollo3.ast.sharesPossibleTypesWith
+import com.apollographql.apollo3.ast.withNullability
 
 /**
  * @param fragmentDefinitions: all the fragments in the current compilation unit.
@@ -147,7 +152,7 @@ internal class ExecutableValidationScope(
       null
     } else if (this !is GQLListType && other is GQLListType) {
       null
-    } else if (this is GQLNamedType && other is GQLNamedType){
+    } else if (this is GQLNamedType && other is GQLNamedType) {
       if (name != other.name) {
         null
       } else {
@@ -212,6 +217,18 @@ internal class ExecutableValidationScope(
       }
     }
 
+    if (nullability != null) {
+      val typeListDimension = fieldDefinition.type.listDimension()
+      val nullabilityListDimension = nullability.listDimension()
+      if (typeListDimension < nullabilityListDimension) {
+        registerIssue(
+            message = "Cannot apply nullability on '$name', the nullability list dimension exceeds the one of the field type.",
+            sourceLocation = nullability.sourceLocation
+        )
+        return
+      }
+    }
+
     directives.forEach {
       validateDirective(it, this) {
         variableUsages.add(it)
@@ -219,6 +236,21 @@ internal class ExecutableValidationScope(
     }
   }
 
+  private fun GQLType.listDimension(): Int {
+    return when (this) {
+      is GQLNonNullType -> this.type.listDimension()
+      is GQLListType -> 1 + this.type.listDimension()
+      else -> 0
+    }
+  }
+
+  private fun GQLNullability.listDimension(): Int {
+    return when (this) {
+      is GQLListNullability -> 1 + this.itemNullability.listDimension()
+      is GQLNullDesignator -> 0
+      is GQLNonNullDesignator -> 0
+    }
+  }
 
   private fun GQLInlineFragment.validate(parentTypeDefinition: GQLTypeDefinition, selectionSetParent: GQLNode, path: String) {
     val tc = typeCondition?.name ?: parentTypeDefinition.name
@@ -485,8 +517,8 @@ internal class ExecutableValidationScope(
     val fieldA = fieldWithParentA.field
     val fieldB = fieldWithParentB.field
 
-    val typeA = fieldA.definitionFromScope(schema, parentTypeDefinitionA)?.type
-    val typeB = fieldB.definitionFromScope(schema, parentTypeDefinitionB)?.type
+    val typeA = fieldA.definitionFromScope(schema, parentTypeDefinitionA)?.type?.withNullability(fieldA.nullability)
+    val typeB = fieldB.definitionFromScope(schema, parentTypeDefinitionB)?.type?.withNullability(fieldB.nullability)
     if (typeA == null || typeB == null) {
       // will be caught by other validation rules
       return
@@ -603,6 +635,7 @@ internal class ExecutableValidationScope(
         }
         true
       }
+
       is GQLObjectValue -> {
         if (valueB !is GQLObjectValue) {
           return false
@@ -618,6 +651,7 @@ internal class ExecutableValidationScope(
         }
         true
       }
+
       is GQLVariableValue -> (valueB as? GQLVariableValue)?.name == valueA.name
     }
   }
@@ -726,6 +760,7 @@ internal class ExecutableValidationScope(
           // if it is, we just skip this field and let other validation report the error
           typeDefinition?.let { FieldWithParent(selection, it) }
         }
+
         is GQLInlineFragment -> selection.collectFields(parentType)
         is GQLFragmentSpread -> selection.collectFields()
       }
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Lexer.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Lexer.kt
index 16f16eba03..da4c55642e 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Lexer.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Lexer.kt
@@ -101,6 +101,7 @@ internal class Lexer(val src: String) {
         }
 
         '!' -> return Token.ExclamationPoint(start, line, column(start))
+        '?' -> return Token.QuestionMark(start, line, column(start))
         '$' -> return Token.Dollar(start, line, column(start))
         '&' -> return Token.Ampersand(start, line, column(start))
         '(' -> return Token.LeftParenthesis(start, line, column(start))
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Parser.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Parser.kt
index c4cecd55ef..50efb98196 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Parser.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Parser.kt
@@ -1,63 +1,23 @@
 package com.apollographql.apollo3.ast.internal
 
-import com.apollographql.apollo3.ast.GQLArgument
-import com.apollographql.apollo3.ast.GQLBooleanValue
-import com.apollographql.apollo3.ast.GQLDefinition
-import com.apollographql.apollo3.ast.GQLDirective
-import com.apollographql.apollo3.ast.GQLDirectiveDefinition
-import com.apollographql.apollo3.ast.GQLDirectiveLocation
-import com.apollographql.apollo3.ast.GQLDocument
-import com.apollographql.apollo3.ast.GQLEnumTypeDefinition
-import com.apollographql.apollo3.ast.GQLEnumTypeExtension
-import com.apollographql.apollo3.ast.GQLEnumValue
-import com.apollographql.apollo3.ast.GQLEnumValueDefinition
-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.GQLInputObjectTypeDefinition
-import com.apollographql.apollo3.ast.GQLInputObjectTypeExtension
-import com.apollographql.apollo3.ast.GQLInputValueDefinition
-import com.apollographql.apollo3.ast.GQLIntValue
-import com.apollographql.apollo3.ast.GQLInterfaceTypeDefinition
-import com.apollographql.apollo3.ast.GQLInterfaceTypeExtension
-import com.apollographql.apollo3.ast.GQLListType
-import com.apollographql.apollo3.ast.GQLListValue
-import com.apollographql.apollo3.ast.GQLNamedType
-import com.apollographql.apollo3.ast.GQLNonNullType
-import com.apollographql.apollo3.ast.GQLNullValue
-import com.apollographql.apollo3.ast.GQLObjectField
-import com.apollographql.apollo3.ast.GQLObjectTypeDefinition
-import com.apollographql.apollo3.ast.GQLObjectTypeExtension
-import com.apollographql.apollo3.ast.GQLObjectValue
-import com.apollographql.apollo3.ast.GQLOperationDefinition
-import com.apollographql.apollo3.ast.GQLOperationTypeDefinition
-import com.apollographql.apollo3.ast.GQLScalarTypeDefinition
-import com.apollographql.apollo3.ast.GQLScalarTypeExtension
-import com.apollographql.apollo3.ast.GQLSchemaDefinition
-import com.apollographql.apollo3.ast.GQLSchemaExtension
-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.GQLUnionTypeDefinition
-import com.apollographql.apollo3.ast.GQLUnionTypeExtension
-import com.apollographql.apollo3.ast.GQLValue
-import com.apollographql.apollo3.ast.GQLVariableDefinition
-import com.apollographql.apollo3.ast.GQLVariableValue
-import com.apollographql.apollo3.ast.SourceLocation
-
-internal class Parser(src: String, private val withSourceLocation: Boolean, val filePath: String?) {
+import com.apollographql.apollo3.ast.*
+
+internal class Parser(
+    src: String,
+    options: ParserOptions,
+    private val filePath: String?
+) {
   private val lexer = Lexer(src)
   private var token = lexer.nextToken()
   private var lastToken = token
   private var lookaheadToken: Token? = null
+  private val withSourceLocation = options.withSourceLocation
+  private val allowClientControlledNullability = options.allowClientControlledNullability
+  private val allowEmptyDocuments = options.allowEmptyDocuments
 
-  fun parseDocument(allowEmpty: Boolean): GQLDocument {
+  fun parseDocument(): GQLDocument {
     val start = token
-    val definitions = if (allowEmpty) {
+    val definitions = if (allowEmptyDocuments) {
       parseList<Token.StartOfFile, Token.EndOfFile, GQLDefinition>(::parseDefinition)
     } else {
       parseNonEmptyList<Token.StartOfFile, Token.EndOfFile, GQLDefinition>(::parseDefinition)
@@ -255,7 +215,12 @@ internal class Parser(src: String, private val withSourceLocation: Boolean, val
     }
 
     val arguments = parseArguments(const = false)
+    var nullability: GQLNullability? = null
+    if (allowClientControlledNullability) {
+      nullability = parseNullability()
+    }
     val directives = parseDirectives(const = false)
+
     val selections = parseOptionalSelectionSet()
     return GQLField(
         sourceLocation = sourceLocation(start),
@@ -263,7 +228,58 @@ internal class Parser(src: String, private val withSourceLocation: Boolean, val
         name = name,
         arguments = arguments,
         directives = directives,
-        selections = selections
+        selections = selections,
+        nullability = nullability,
+    )
+  }
+
+  private fun parseNullabilityDesignator(): GQLNullability? {
+    return when(token) {
+      is Token.ExclamationPoint -> {
+        val sourceLocation = sourceLocation()
+        advance()
+        GQLNonNullDesignator(sourceLocation)
+      }
+
+      is Token.QuestionMark -> {
+        val sourceLocation = sourceLocation()
+        advance()
+        GQLNullDesignator(sourceLocation)
+      }
+
+      else -> {
+        null
+      }
+    }
+  }
+
+  private fun parseNullability(): GQLNullability? {
+    return when (token) {
+      is Token.LeftBracket -> {
+        parseListNullability()
+      }
+      else -> {
+        parseNullabilityDesignator()
+      }
+    }
+  }
+
+  private fun parseListNullability(): GQLListNullability {
+    val start = token
+    val sourceLocation = sourceLocation()
+
+    expectToken<Token.LeftBracket>()
+    val ofNullability = parseNullability()
+    expectToken<Token.RightBracket>()
+
+    if (ofNullability == null) {
+      throw ParserException("List nullability must not be empty", start)
+    }
+
+    return GQLListNullability(
+        sourceLocation = sourceLocation,
+        itemNullability = ofNullability,
+        selfNullability = parseNullabilityDesignator()
     )
   }
 
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Token.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Token.kt
index da4162c367..0c525783b1 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Token.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/internal/Token.kt
@@ -19,6 +19,10 @@ internal sealed class Token(val start: kotlin.Int, val end: kotlin.Int, val line
     override fun toString() = "!"
   }
 
+  class QuestionMark(start: kotlin.Int, line: kotlin.Int, column: kotlin.Int) : Token(start, start + 1, line, column) {
+    override fun toString() = "?"
+  }
+
   class Dollar(start: kotlin.Int, line: kotlin.Int, column: kotlin.Int) : Token(start, start + 1, line, column) {
     override fun toString() = "$"
   }
diff --git a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/transformation/add_required_fields.kt b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/transformation/add_required_fields.kt
index 61e271dba9..85819e534c 100644
--- a/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/transformation/add_required_fields.kt
+++ b/libraries/apollo-ast/src/commonMain/kotlin/com/apollographql/apollo3/ast/transformation/add_required_fields.kt
@@ -164,6 +164,7 @@ private fun buildField(name: String): GQLField {
       selections = emptyList(),
       sourceLocation = null,
       directives = emptyList(),
-      alias = null
+      alias = null,
+      nullability = null
   )
 }
diff --git a/libraries/apollo-ast/src/jvmMain/kotlin/com/apollographql/apollo3/ast/internal/antlr_to_gql.kt b/libraries/apollo-ast/src/jvmMain/kotlin/com/apollographql/apollo3/ast/internal/antlr_to_gql.kt
index 90d61305fb..015b4f1290 100644
--- a/libraries/apollo-ast/src/jvmMain/kotlin/com/apollographql/apollo3/ast/internal/antlr_to_gql.kt
+++ b/libraries/apollo-ast/src/jvmMain/kotlin/com/apollographql/apollo3/ast/internal/antlr_to_gql.kt
@@ -300,7 +300,8 @@ private class AntlrToGQLScope(val filePath: String?) {
         name = name().text,
         arguments = arguments()?.parse()?.arguments.orEmpty(),
         directives = directives().parse(),
-        selections = selectionSet()?.parse()?.selections.orEmpty()
+        selections = selectionSet()?.parse()?.selections.orEmpty(),
+        nullability = null
     )
   }
 
diff --git a/libraries/apollo-compiler/src/main/kotlin/com/apollographql/apollo3/compiler/ApolloCompiler.kt b/libraries/apollo-compiler/src/main/kotlin/com/apollographql/apollo3/compiler/ApolloCompiler.kt
index b66d4bfd68..1c28319e04 100644
--- a/libraries/apollo-compiler/src/main/kotlin/com/apollographql/apollo3/compiler/ApolloCompiler.kt
+++ b/libraries/apollo-compiler/src/main/kotlin/com/apollographql/apollo3/compiler/ApolloCompiler.kt
@@ -113,7 +113,7 @@ object ApolloCompiler {
     val definitions = mutableListOf<GQLDefinition>()
     val parseIssues = mutableListOf<Issue>()
     map { file ->
-      val parseResult = file.parseAsGQLDocument(options = ParserOptions(useAntlr = useAntlr))
+      val parseResult = file.parseAsGQLDocument(options = ParserOptions.Builder().useAntlr(useAntlr).build())
       if (parseResult.issues.isNotEmpty()) {
         parseIssues.addAll(parseResult.issues)
       } else {
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 0ffcd9685c..3762e9b7f2 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
@@ -13,6 +13,7 @@ import com.apollographql.apollo3.ast.GQLListValue
 import com.apollographql.apollo3.ast.GQLNode
 import com.apollographql.apollo3.ast.GQLNonNullType
 import com.apollographql.apollo3.ast.GQLNullValue
+import com.apollographql.apollo3.ast.GQLNullability
 import com.apollographql.apollo3.ast.GQLObjectTypeDefinition
 import com.apollographql.apollo3.ast.GQLObjectValue
 import com.apollographql.apollo3.ast.GQLOperationDefinition
@@ -42,6 +43,7 @@ import com.apollographql.apollo3.ast.responseName
 import com.apollographql.apollo3.ast.rootTypeDefinition
 import com.apollographql.apollo3.ast.toUtf8
 import com.apollographql.apollo3.ast.transform
+import com.apollographql.apollo3.ast.withNullability
 import com.apollographql.apollo3.compiler.MODELS_OPERATION_BASED
 import com.apollographql.apollo3.compiler.MODELS_OPERATION_BASED_WITH_INTERFACES
 import com.apollographql.apollo3.compiler.MODELS_RESPONSE_BASED
@@ -414,6 +416,7 @@ internal class IrOperationsBuilder(
 
       val description: String?,
       val type: GQLType,
+      val nullability: GQLNullability?,
       val deprecationReason: String?,
       val optInFeature: String?,
       val forceNonNull: Boolean,
@@ -446,6 +449,7 @@ internal class IrOperationsBuilder(
           condition = gqlField.directives.toIncludeBooleanExpression(),
           selections = gqlField.selections,
           type = fieldDefinition.type,
+          nullability = gqlField.nullability,
           description = fieldDefinition.description,
           deprecationReason = fieldDefinition.directives.findDeprecationReason(),
           optInFeature = fieldDefinition.directives.findOptInFeature(schema),
@@ -521,7 +525,7 @@ internal class IrOperationsBuilder(
        */
       usedFields.putType(first.type.rawType().name)
 
-      var irType = first.type.toIr()
+      var irType = first.type.withNullability(first.nullability).toIr()
       if (forceNonNull) {
         irType = irType.makeNonNull()
       } else if (forceOptional) {


Test patch:
diff --git a/build-logic/src/main/kotlin/com/apollographql/apollo3/buildlogic/plugin/TestConventionPlugin.kt b/build-logic/src/main/kotlin/com/apollographql/apollo3/buildlogic/plugin/TestConventionPlugin.kt
index 2ba2831a79..e8b72bd489 100644
--- a/build-logic/src/main/kotlin/com/apollographql/apollo3/buildlogic/plugin/TestConventionPlugin.kt
+++ b/build-logic/src/main/kotlin/com/apollographql/apollo3/buildlogic/plugin/TestConventionPlugin.kt
@@ -1,5 +1,6 @@
 package com.apollographql.apollo3.buildlogic.plugin
 
+import commonSetup
 import configureJavaAndKotlinCompilers
 import configureMppTestsDefaults
 import configureTesting
@@ -16,6 +17,7 @@ class TestConventionPlugin : Plugin<Project> {
     with(project) {
       extensions.create("apolloTest", Extension::class.java)
 
+      commonSetup()
       configureJavaAndKotlinCompilers()
       optIn(
           "com.apollographql.apollo3.annotations.ApolloExperimental",
diff --git a/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/ParserTest.kt b/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/CommonParserTest.kt
similarity index 99%
rename from libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/ParserTest.kt
rename to libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/CommonParserTest.kt
index a769a156b3..d87286de4e 100644
--- a/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/ParserTest.kt
+++ b/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/CommonParserTest.kt
@@ -11,7 +11,7 @@ import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.fail
 
-class ParserTest {
+class CommonParserTest {
   @Test
   fun extraTokensAtEndOfFileAreDetected() {
     try {
diff --git a/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/GQLTest.kt b/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/GQLTest.kt
new file mode 100644
index 0000000000..32ea23e469
--- /dev/null
+++ b/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/GQLTest.kt
@@ -0,0 +1,18 @@
+package com.apollographql.apollo3.graphql.ast.test
+
+import com.apollographql.apollo3.ast.GQLListNullability
+import com.apollographql.apollo3.ast.GQLNullDesignator
+import com.apollographql.apollo3.ast.parseAsGQLType
+import com.apollographql.apollo3.ast.pretty
+import com.apollographql.apollo3.ast.withNullability
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class GQLTest {
+  @Test
+  fun type() {
+    val newType = "[String!]!".parseAsGQLType().getOrThrow().withNullability(GQLListNullability(selfNullability = null, itemNullability = GQLNullDesignator()))
+
+    assertEquals("[String]!", newType.pretty())
+  }
+}
\ No newline at end of file
diff --git a/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/TransformTest.kt b/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/TransformTest.kt
index 1ca22aa975..5187133b56 100644
--- a/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/TransformTest.kt
+++ b/libraries/apollo-ast/src/commonTest/kotlin/com/apollographql/apollo3/graphql/ast/test/TransformTest.kt
@@ -85,6 +85,7 @@ class TransformTest {
             arguments = emptyList(),
             directives = emptyList(),
             selections = emptyList(),
+            nullability = null
         )
         TransformResult.Replace(
             it.copy(
diff --git a/libraries/apollo-ast/src/jvmTest/kotlin/com/apollographql/apollo3/graphql/ast/test/ParserTest.kt b/libraries/apollo-ast/src/jvmTest/kotlin/com/apollographql/apollo3/graphql/ast/test/ParserTest.kt
new file mode 100644
index 0000000000..6a762c7461
--- /dev/null
+++ b/libraries/apollo-ast/src/jvmTest/kotlin/com/apollographql/apollo3/graphql/ast/test/ParserTest.kt
@@ -0,0 +1,81 @@
+package com.apollographql.apollo3.graphql.ast.test
+
+import com.apollographql.apollo3.ast.Issue
+import com.apollographql.apollo3.ast.parseAsGQLDocument
+import com.google.testing.junit.testparameterinjector.TestParameter
+import com.google.testing.junit.testparameterinjector.TestParameterInjector
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import kotlin.test.assertEquals
+
+@RunWith(TestParameterInjector::class)
+class ParserTest {
+  @Test
+  fun parserProducesExpectedValues(@TestParameter(valuesProvider = ParametersProvider::class) graphqlFile: File) {
+    checkExpected(graphqlFile) {
+      graphqlFile.parseAsGQLDocument()
+          .issues
+          .serialize()
+    }
+  }
+
+  class ParametersProvider : TestParameter.TestParameterValuesProvider {
+    override fun provideValues(): List<File> {
+      return File("test-fixtures/parser/")
+          .listFiles()!!
+          .filter { it.extension == "graphql" }
+          .sortedBy { it.name }
+          .filter {
+            testFilterMatches(it.name)
+          }
+    }
+  }
+
+  /**
+   * run the block and checks the result against the .expected file. Will overwrite the result if required
+   *
+   * @param block the callback to produce the result.
+   */
+  fun checkExpected(graphQLFile: File, block: () -> String) {
+    val actual = block()
+
+    val expectedFile = File(graphQLFile.parent, graphQLFile.nameWithoutExtension + ".expected")
+    val expected = try {
+      expectedFile.readText()
+    } catch (e: Exception) {
+      null
+    }
+
+    if (shouldUpdateTestFixtures()) {
+      expectedFile.writeText(actual)
+    } else {
+      assertEquals(expected, actual)
+    }
+  }
+
+  companion object {
+    private const val separator = "\n------------\n"
+
+    fun List<Issue>.serialize() = joinToString(separator) {
+      "${it.severity}: ${it.javaClass.simpleName} (${it.sourceLocation?.line}:${it.sourceLocation?.column})\n${it.message}"
+    }
+
+    fun shouldUpdateTestFixtures(): Boolean {
+      if (System.getenv("updateTestFixtures") != null) {
+        return true
+      }
+
+      return when (System.getProperty("updateTestFixtures")?.trim()) {
+        "on", "true", "1" -> true
+        else -> false
+      }
+    }
+
+    private fun testFilterMatches(value: String): Boolean {
+      val testFilter = System.getenv("testFilter") ?: System.getProperty("testFilter") ?: return true
+
+      return Regex(testFilter).containsMatchIn(value)
+    }
+  }
+}
\ No newline at end of file
diff --git a/libraries/apollo-ast/test-fixtures/parser/ccn-invalid-2.expected b/libraries/apollo-ast/test-fixtures/parser/ccn-invalid-2.expected
new file mode 100644
index 0000000000..7cb7a83b56
--- /dev/null
+++ b/libraries/apollo-ast/test-fixtures/parser/ccn-invalid-2.expected
@@ -0,0 +1,2 @@
+ERROR: ParsingError (3:1)
+Expected RightBracket, found '}'.
\ No newline at end of file
diff --git a/libraries/apollo-ast/test-fixtures/parser/ccn-invalid-2.graphql b/libraries/apollo-ast/test-fixtures/parser/ccn-invalid-2.graphql
new file mode 100644
index 0000000000..41fe99f35f
--- /dev/null
+++ b/libraries/apollo-ast/test-fixtures/parser/ccn-invalid-2.graphql
@@ -0,0 +1,3 @@
+{
+  foo0[
+}
diff --git a/libraries/apollo-ast/test-fixtures/parser/ccn-invalid.expected b/libraries/apollo-ast/test-fixtures/parser/ccn-invalid.expected
new file mode 100644
index 0000000000..9b8b6b9778
--- /dev/null
+++ b/libraries/apollo-ast/test-fixtures/parser/ccn-invalid.expected
@@ -0,0 +1,2 @@
+ERROR: ParsingError (2:7)
+List nullability must not be empty
\ No newline at end of file
diff --git a/libraries/apollo-ast/test-fixtures/parser/ccn-invalid.graphql b/libraries/apollo-ast/test-fixtures/parser/ccn-invalid.graphql
new file mode 100644
index 0000000000..24421ed5ee
--- /dev/null
+++ b/libraries/apollo-ast/test-fixtures/parser/ccn-invalid.graphql
@@ -0,0 +1,3 @@
+{
+  foo0[]
+}
diff --git a/libraries/apollo-ast/test-fixtures/parser/ccn-valid.expected b/libraries/apollo-ast/test-fixtures/parser/ccn-valid.expected
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/libraries/apollo-ast/test-fixtures/parser/ccn-valid.graphql b/libraries/apollo-ast/test-fixtures/parser/ccn-valid.graphql
new file mode 100644
index 0000000000..c83bf501a5
--- /dev/null
+++ b/libraries/apollo-ast/test-fixtures/parser/ccn-valid.graphql
@@ -0,0 +1,7 @@
+{
+  nullable!
+  nonNullable?
+  nullableListOfNullable[!]!
+  nullableListOfNonNullable[?]!
+  nonNullableListOfNullable[!]?
+}
diff --git a/libraries/apollo-compiler/src/test/kotlin/com/apollographql/apollo3/compiler/CodegenTest.kt b/libraries/apollo-compiler/src/test/kotlin/com/apollographql/apollo3/compiler/CodegenTest.kt
index 99e70da65c..c4757c98ec 100644
--- a/libraries/apollo-compiler/src/test/kotlin/com/apollographql/apollo3/compiler/CodegenTest.kt
+++ b/libraries/apollo-compiler/src/test/kotlin/com/apollographql/apollo3/compiler/CodegenTest.kt
@@ -16,8 +16,6 @@ import com.apollographql.apollo3.compiler.hooks.ApolloCompilerKotlinHooks
 import com.apollographql.apollo3.compiler.hooks.internal.AddInternalCompilerHooks
 import com.google.testing.junit.testparameterinjector.TestParameter
 import com.google.testing.junit.testparameterinjector.TestParameterInjector
-import okio.buffer
-import okio.source
 import org.junit.AfterClass
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -151,7 +149,6 @@ class CodegenTest {
   }
 
   class ParametersProvider : TestParameter.TestParameterValuesProvider {
-    @OptIn(ExperimentalStdlibApi::class)
     override fun provideValues(): List<Parameters> {
       return File("src/test/graphql/com/example/")
           .listFiles()!!
diff --git a/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn.expected b/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn.expected
new file mode 100644
index 0000000000..54972f54bb
--- /dev/null
+++ b/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn.expected
@@ -0,0 +1,5 @@
+ERROR: ValidationError (5:13)
+Cannot apply nullability on 'nullable', the nullability list dimension exceeds the one of the field type.
+------------
+ERROR: ValidationError (6:17)
+Cannot apply nullability on 'deepList', the nullability list dimension exceeds the one of the field type.
\ No newline at end of file
diff --git a/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn.graphql b/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn.graphql
new file mode 100644
index 0000000000..f4a9e270d8
--- /dev/null
+++ b/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn.graphql
@@ -0,0 +1,10 @@
+query Foo {
+    nullable!
+    nonNullable?
+    # too much nesting
+    nullable[!]!
+    a0: deepList[[[[!]]]]
+    # this is ok
+    a1: deepList[[!]]
+}
+
diff --git a/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn_merging.expected b/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn_merging.expected
new file mode 100644
index 0000000000..97229cb36d
--- /dev/null
+++ b/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn_merging.expected
@@ -0,0 +1,5 @@
+ERROR: ValidationError (3:9)
+`nullable` cannot be merged with `nullable` (at ccn_merging.graphql: (5, 5)): they have different types. Use different aliases on the fields to fetch both if this was intentional.
+------------
+ERROR: ValidationError (5:5)
+`nullable` cannot be merged with `nullable` (at ccn_merging.graphql: (3, 9)): they have different types. Use different aliases on the fields to fetch both if this was intentional.
\ No newline at end of file
diff --git a/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn_merging.graphql b/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn_merging.graphql
new file mode 100644
index 0000000000..05dea09e17
--- /dev/null
+++ b/libraries/apollo-compiler/src/test/validation/operation/ccn/ccn_merging.graphql
@@ -0,0 +1,6 @@
+query Foo {
+    ... on Query {
+        nullable!
+    }
+    nullable?
+}
diff --git a/libraries/apollo-compiler/src/test/validation/operation/ccn/schema.graphqls b/libraries/apollo-compiler/src/test/validation/operation/ccn/schema.graphqls
new file mode 100644
index 0000000000..dccc17d3e3
--- /dev/null
+++ b/libraries/apollo-compiler/src/test/validation/operation/ccn/schema.graphqls
@@ -0,0 +1,8 @@
+type Query {
+    nullable: String
+    nonNullable: String!
+    nullableListOfNullable: [String]
+    nullableListOfNonNullable: [String!]
+    nonNullableListOfNullable: [String]!
+    deepList: [[[String]]]
+}
diff --git a/tests/ast-benchmark/src/jmh/kotlin/benchmark/Benchmark.kt b/tests/ast-benchmark/src/jmh/kotlin/benchmark/Benchmark.kt
index 2dc6a1f387..e177f2479c 100644
--- a/tests/ast-benchmark/src/jmh/kotlin/benchmark/Benchmark.kt
+++ b/tests/ast-benchmark/src/jmh/kotlin/benchmark/Benchmark.kt
@@ -46,15 +46,14 @@ open class Benchmark {
   @Benchmark
   fun antlrTest(): Double {
     return testFiles.sumOf {
-      it.source().buffer().parseAsGQLDocument(options = ParserOptions(useAntlr = true)).getOrThrow().definitions.size.toDouble()
+      it.source().buffer().parseAsGQLDocument(options = ParserOptions.Builder().useAntlr(true).build()).getOrThrow().definitions.size.toDouble()
     }
   }
 
   @Benchmark
   fun parserTest(): Double {
     return testFiles.sumOf {
-      it.source().buffer().parseAsGQLDocument(options = ParserOptions(useAntlr = false)).getOrThrow().definitions.size.toDouble()
+      it.source().buffer().parseAsGQLDocument(options = ParserOptions.Builder().useAntlr(false).build()).getOrThrow().definitions.size.toDouble()
     }
   }
-
 }
\ No newline at end of file
diff --git a/tests/ccn/build.gradle.kts b/tests/ccn/build.gradle.kts
new file mode 100644
index 0000000000..34b586084f
--- /dev/null
+++ b/tests/ccn/build.gradle.kts
@@ -0,0 +1,19 @@
+plugins {
+  id("org.jetbrains.kotlin.jvm")
+  id("apollo.test")
+  id("com.apollographql.apollo3")
+}
+
+dependencies {
+  implementation(libs.apollo.api)
+  implementation(libs.apollo.testingsupport)
+  testImplementation(libs.kotlin.test)
+  testImplementation(libs.junit)
+  testImplementation(libs.okhttp)
+}
+
+apollo {
+  service("service") {
+    packageName.set("com.example")
+  }
+}
diff --git a/tests/ccn/src/main/graphql/operation.graphql b/tests/ccn/src/main/graphql/operation.graphql
new file mode 100644
index 0000000000..a8b7236156
--- /dev/null
+++ b/tests/ccn/src/main/graphql/operation.graphql
@@ -0,0 +1,15 @@
+query Get1 {
+  nullable!
+  nonNullable?
+}
+
+query GetList {
+  user {
+    friends[?] {
+      name
+    }
+    enemies[!]! {
+      name
+    }
+  }
+}
\ No newline at end of file
diff --git a/tests/ccn/src/main/graphql/schema.graphqls b/tests/ccn/src/main/graphql/schema.graphqls
new file mode 100644
index 0000000000..4c46895b42
--- /dev/null
+++ b/tests/ccn/src/main/graphql/schema.graphqls
@@ -0,0 +1,14 @@
+type Query {
+  nullable: Int
+  nonNullable: Int!
+  deep: [[[Int]]]
+  user: User
+}
+
+type User {
+  name: String!
+  friends: [User!]!
+  enemies: [User]
+}
+
+
diff --git a/tests/ccn/src/test/kotlin/test/CcnTest.kt b/tests/ccn/src/test/kotlin/test/CcnTest.kt
new file mode 100644
index 0000000000..78c968bc5d
--- /dev/null
+++ b/tests/ccn/src/test/kotlin/test/CcnTest.kt
@@ -0,0 +1,58 @@
+package test
+
+import com.apollographql.apollo3.mockserver.enqueue
+import com.apollographql.apollo3.testing.mockServerTest
+import com.example.Get1Query
+import com.example.GetListQuery
+import org.intellij.lang.annotations.Language
+import org.junit.Test
+import kotlin.test.assertEquals
+
+class CcnTest {
+  @Test
+  fun testScalar() = mockServerTest {
+    mockServer.enqueue("""
+      {
+        "data": {
+          "nullable": 41,
+          "nonNullable": null
+        }
+      }
+    """.trimIndent())
+
+    val response = apolloClient.query(Get1Query()).execute()
+
+    assertEquals(41, response.data!!.nullable)
+    assertEquals(null, response.data!!.nonNullable)
+  }
+
+  @Test
+  fun testList() = mockServerTest {
+    @Language("JSON")
+    val json = """
+      {
+        "data": {
+          "user": {
+            "friends": [
+              null
+            ],
+            "enemies": [
+              {
+                "name": "nullability"
+              }
+            ]
+          }
+        }
+      }
+    """.trimIndent()
+    mockServer.enqueue(json)
+
+    val response = apolloClient.query(GetListQuery()).execute()
+
+    if (response.exception != null) {
+      response.exception?.printStackTrace()
+    }
+    assertEquals(null, response.data!!.user!!.friends[0]?.name)
+    assertEquals("nullability", response.data!!.user!!.enemies[0].name)
+  }
+}