diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 00000000..54a659d0 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,43 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile +import org.jetbrains.kotlin.samWithReceiver.gradle.SamWithReceiverExtension +import org.jetbrains.kotlin.samWithReceiver.gradle.SamWithReceiverGradleSubplugin + +plugins { + `embedded-kotlin` + id("java-gradle-plugin") +} + +plugins.apply(SamWithReceiverGradleSubplugin::class.java) +extensions.configure(SamWithReceiverExtension::class.java) { + annotations(HasImplicitReceiver::class.qualifiedName!!) +} + +group = "com.apollographql.cache.build" + +dependencies { + compileOnly(gradleApi()) + compileOnly(libs.kotlin.plugin) +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(17)) +} + +tasks.withType().configureEach { + options.release.set(17) +} + +tasks.withType(KotlinJvmCompile::class.java).configureEach { + kotlinOptions.jvmTarget = "17" +} + +gradlePlugin { + plugins { + register("build.logic") { + id = "build.logic" + // This plugin is only used for loading the jar using the Marker but never applied + // We don't need it. + implementationClass = "build.logic.Unused" + } + } +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 00000000..09f9b70e --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,11 @@ +rootProject.name = "build-logic" + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +apply(from = "../gradle/repositories.gradle.kts") diff --git a/build-logic/src/main/kotlin/Kmp.kt b/build-logic/src/main/kotlin/Kmp.kt new file mode 100644 index 00000000..d0e29e53 --- /dev/null +++ b/build-logic/src/main/kotlin/Kmp.kt @@ -0,0 +1,99 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +enum class AppleTargets { + All, + Host, +} + +fun KotlinMultiplatformExtension.configureKmp( + withJs: Boolean, + withWasm: Boolean, + withAndroid: Boolean, + withApple: AppleTargets = AppleTargets.All, +) { + jvm() + when (withApple) { + AppleTargets.All -> { + macosX64() + macosArm64() + iosArm64() + iosX64() + iosSimulatorArm64() + watchosArm32() + watchosArm64() + watchosSimulatorArm64() + tvosArm64() + tvosX64() + tvosSimulatorArm64() + } + + AppleTargets.Host -> { + if (System.getProperty("os.arch") == "aarch64") { + macosArm64() + } else { + macosX64() + } + } + } + if (withJs) { + js(IR) { + nodejs { + testTask { + useMocha { + // Override default 2s timeout + timeout = "120s" + } + } + } + } + } + if (withWasm) { + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + nodejs() + } + } + if (withAndroid) { + androidTarget { + publishAllLibraryVariants() + } + } + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + applyDefaultHierarchyTemplate { + group("common") { + group("concurrent") { + group("native") { + group("apple") + } + group("jvmCommon") { + withJvm() + if (withAndroid) { + withAndroidTarget() + } + } + } + if (withJs || withWasm) { + group("jsCommon") { + if (withJs) { + group("js") { + withJs() + } + } + if (withWasm) { + group("wasmJs") { + withWasmJs() + } + } + } + } + } + } + + sourceSets.configureEach { + languageSettings.optIn("com.apollographql.apollo.annotations.ApolloExperimental") + languageSettings.optIn("com.apollographql.apollo.annotations.ApolloInternal") + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 8b80d18a..289bb8e1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,8 @@ import com.gradleup.librarian.gradle.librarianRoot plugins { + id("build.logic") apply false + alias(libs.plugins.kotlin.multiplatform).apply(false) alias(libs.plugins.kotlin.jvm).apply(false) alias(libs.plugins.android).apply(false) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cb2c1a38..83becec3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ androidx-sqlite = { group = "androidx.sqlite", name = "sqlite", version.ref = "a androidx-sqlite-framework = { group = "androidx.sqlite", name = "sqlite-framework", version.ref = "androidx-sqlite" } androidx-startup-runtime = "androidx.startup:startup-runtime:1.1.1" kotlin-poet = { group = "com.squareup", name = "kotlinpoet", version = "1.18.1" } +kotlin-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin-plugin" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-plugin" } diff --git a/gradle/repositories.gradle.kts b/gradle/repositories.gradle.kts new file mode 100644 index 00000000..213c3c1b --- /dev/null +++ b/gradle/repositories.gradle.kts @@ -0,0 +1,10 @@ +listOf(pluginManagement.repositories, dependencyResolutionManagement.repositories).forEach { + it.apply { + maven { + url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") + } + mavenCentral() + google() + gradlePluginPortal() + } +} diff --git a/normalized-cache-incubating/build.gradle.kts b/normalized-cache-incubating/build.gradle.kts index 6f0a7a95..98b98e48 100644 --- a/normalized-cache-incubating/build.gradle.kts +++ b/normalized-cache-incubating/build.gradle.kts @@ -1,6 +1,4 @@ import com.gradleup.librarian.gradle.librarianModule -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl plugins { alias(libs.plugins.kotlin.multiplatform) @@ -10,43 +8,11 @@ plugins { librarianModule(true) kotlin { - jvm() - macosX64() - macosArm64() - iosArm64() - iosX64() - iosSimulatorArm64() - watchosArm32() - watchosArm64() - watchosSimulatorArm64() - tvosArm64() - tvosX64() - tvosSimulatorArm64() - js(IR) { - nodejs() - } - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - nodejs() - } - - @OptIn(ExperimentalKotlinGradlePluginApi::class) - applyDefaultHierarchyTemplate { - group("common") { - group("concurrent") { - group("apple") - withJvm() - } - group("jsCommon") { - group("js") { - withJs() - } - group("wasmJs") { - withWasmJs() - } - } - } - } + configureKmp( + withJs = true, + withWasm = true, + withAndroid = false, + ) sourceSets { getByName("commonMain") { @@ -65,10 +31,5 @@ kotlin { implementation(libs.apollo.testing.support) } } - - configureEach { - languageSettings.optIn("com.apollographql.apollo.annotations.ApolloExperimental") - languageSettings.optIn("com.apollographql.apollo.annotations.ApolloInternal") - } } } diff --git a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/internal/Normalizer.kt b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/internal/Normalizer.kt index a95f6cfd..a0831201 100644 --- a/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/internal/Normalizer.kt +++ b/normalized-cache-incubating/src/commonMain/kotlin/com/apollographql/cache/normalized/internal/Normalizer.kt @@ -73,8 +73,9 @@ internal class Normalizer( if (compiledFields.isEmpty()) { // If we come here, `obj` contains more data than the CompiledSelections can understand // This happened previously (see https://github.com/apollographql/apollo-kotlin/pull/3636) - // This should not happen anymore but in case it does, we're logging some info here - throw RuntimeException("Cannot find a CompiledField for entry: {${entry.key}: ${entry.value}}, __typename = $typename, key = $key") + // It also happens if there's an always false @include directive (see https://github.com/apollographql/apollo-kotlin/issues/4772) + // For all cache purposes, this is not part of the response and we therefore do not include this in the response + return@mapNotNull null } val includedFields = compiledFields.filter { !it.shouldSkip(variableValues = variables.valueMap) diff --git a/normalized-cache-sqlite-incubating/build.gradle.kts b/normalized-cache-sqlite-incubating/build.gradle.kts index 9393507b..99db6085 100644 --- a/normalized-cache-sqlite-incubating/build.gradle.kts +++ b/normalized-cache-sqlite-incubating/build.gradle.kts @@ -1,5 +1,4 @@ import com.gradleup.librarian.gradle.librarianModule -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi plugins { alias(libs.plugins.kotlin.multiplatform) @@ -10,36 +9,11 @@ plugins { librarianModule(true) kotlin { - jvm() - macosX64() - macosArm64() - iosArm64() - iosX64() - iosSimulatorArm64() - watchosArm32() - watchosArm64() - watchosSimulatorArm64() - tvosArm64() - tvosX64() - tvosSimulatorArm64() - androidTarget { - publishAllLibraryVariants() - } - - @OptIn(ExperimentalKotlinGradlePluginApi::class) - applyDefaultHierarchyTemplate { - group("common") { - group("concurrent") { - group("native") { - group("apple") - } - group("jvmCommon") { - withJvm() - withAndroidTarget() - } - } - } - } + configureKmp( + withJs = false, + withWasm = false, + withAndroid = true, + ) } android { @@ -126,11 +100,6 @@ kotlin { implementation(libs.apollo.testing.support) } } - - configureEach { - languageSettings.optIn("com.apollographql.apollo.annotations.ApolloExperimental") - languageSettings.optIn("com.apollographql.apollo.annotations.ApolloInternal") - } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 50b36b73..b85c4ce8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,15 +1,9 @@ pluginManagement { - listOf(repositories, dependencyResolutionManagement.repositories).forEach { - it.apply { - maven { - url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") - } - mavenCentral() - google() - } - } + includeBuild("build-logic") } +apply(from = "gradle/repositories.gradle.kts") + include( "normalized-cache-incubating", "normalized-cache-sqlite-incubating", diff --git a/tests/build.gradle.kts b/tests/build.gradle.kts index 2711613c..a1c8bf18 100644 --- a/tests/build.gradle.kts +++ b/tests/build.gradle.kts @@ -1,14 +1,6 @@ -buildscript { - repositories { - maven { - url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") - } - mavenCentral() - google() - } -} - plugins { + id("build.logic") apply false + alias(libs.plugins.kotlin.multiplatform).apply(false) alias(libs.plugins.apollo).apply(false) alias(libs.plugins.apollo.external).apply(false) diff --git a/tests/cache-control/build.gradle.kts b/tests/cache-control/build.gradle.kts index 76e07f49..618fa6a4 100644 --- a/tests/cache-control/build.gradle.kts +++ b/tests/cache-control/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi - plugins { alias(libs.plugins.kotlin.multiplatform) // TODO: Use the external plugin for now - switch to the regular one when Schema is not relocated @@ -8,32 +6,12 @@ plugins { } kotlin { - jvm() - macosX64() - macosArm64() - iosArm64() - iosX64() - iosSimulatorArm64() - watchosArm32() - watchosArm64() - watchosSimulatorArm64() - tvosArm64() - tvosX64() - tvosSimulatorArm64() - - @OptIn(ExperimentalKotlinGradlePluginApi::class) - applyDefaultHierarchyTemplate { - group("common") { - group("concurrent") { - group("native") { - group("apple") - } - group("jvmCommon") { - withJvm() - } - } - } - } + configureKmp( + withJs = false, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) sourceSets { getByName("commonMain") { @@ -57,11 +35,6 @@ kotlin { implementation(libs.slf4j.nop) } } - - configureEach { - languageSettings.optIn("com.apollographql.apollo.annotations.ApolloExperimental") - languageSettings.optIn("com.apollographql.apollo.annotations.ApolloInternal") - } } } diff --git a/tests/cache-variables-arguments/README.md b/tests/cache-variables-arguments/README.md new file mode 100644 index 00000000..0c9e61b7 --- /dev/null +++ b/tests/cache-variables-arguments/README.md @@ -0,0 +1,3 @@ +Test using the cache with `@include` and different arguments, including variables +* See https://kotlinlang.slack.com/archives/C01A6KM1SBZ/p1696977296566129 +* See https://github.com/apollographql/apollo-kotlin/issues/5186 \ No newline at end of file diff --git a/tests/cache-variables-arguments/build.gradle.kts b/tests/cache-variables-arguments/build.gradle.kts new file mode 100644 index 00000000..ef320ee6 --- /dev/null +++ b/tests/cache-variables-arguments/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + // TODO: Use the external plugin for now - switch to the regular one when Schema is not relocated + // See https://github.com/apollographql/apollo-kotlin/pull/6176 + alias(libs.plugins.apollo.external) +} + +kotlin { + configureKmp( + withJs = true, + withWasm = true, + withAndroid = false, + withApple = AppleTargets.Host, + ) + + sourceSets { + getByName("commonMain") { + dependencies { + implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + getByName("commonTest") { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.apollo.testing.support) + } + } + } +} + +apollo { + service("service") { + packageName.set("cache.include") + } +} diff --git a/tests/cache-variables-arguments/src/commonMain/graphql/operation.graphql b/tests/cache-variables-arguments/src/commonMain/graphql/operation.graphql new file mode 100644 index 00000000..236bf9b3 --- /dev/null +++ b/tests/cache-variables-arguments/src/commonMain/graphql/operation.graphql @@ -0,0 +1,35 @@ +query GetUser($withDetails: Boolean!) { + user { + id + ...userDetails @include(if: $withDetails) + } +} + +fragment userDetails on User { + name + email +} + +query VariableDefaultValueEmpty($b: B = {}) { + a0(b: $b) +} + +query VariableDefaultValueWithC($b: B = {c: 4}) { + a0(b: $b) +} + +query VariableAbsent($b: B) { + a0(b: $b) +} + +query VariableDefaultValueNull($b: B = null) { + a0(b: $b) +} + +query AbsentArgumentWithArgumentDefaultValue { + a1 +} + +query PresentArgumentEmpty { + a1(b: {}) +} \ No newline at end of file diff --git a/tests/cache-variables-arguments/src/commonMain/graphql/schema.graphqls b/tests/cache-variables-arguments/src/commonMain/graphql/schema.graphqls new file mode 100644 index 00000000..ca77bbd3 --- /dev/null +++ b/tests/cache-variables-arguments/src/commonMain/graphql/schema.graphqls @@ -0,0 +1,17 @@ +type Query { + user: User! + a0(b: B): Int + a1(b: B = {}): Int + a2(b: B = {c: 2}): Int +} + +input B { + c: Int = 3 + d: Int +} + +type User { + id: ID! + name: String! + email: String! +} diff --git a/tests/cache-variables-arguments/src/commonTest/kotlin/test/CacheArgumentTest.kt b/tests/cache-variables-arguments/src/commonTest/kotlin/test/CacheArgumentTest.kt new file mode 100644 index 00000000..a7184df9 --- /dev/null +++ b/tests/cache-variables-arguments/src/commonTest/kotlin/test/CacheArgumentTest.kt @@ -0,0 +1,90 @@ +package test + +import cache.include.AbsentArgumentWithArgumentDefaultValueQuery +import cache.include.PresentArgumentEmptyQuery +import cache.include.VariableAbsentQuery +import cache.include.VariableDefaultValueEmptyQuery +import cache.include.VariableDefaultValueNullQuery +import cache.include.VariableDefaultValueWithCQuery +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.Operation +import com.apollographql.cache.normalized.api.TypePolicyCacheKeyGenerator +import com.apollographql.cache.normalized.api.normalize +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Test to check coercion of various variables/arguments combinations. + * + * I am not 100% certain this is all correct but hopefully this is a good base for future + * improvements + * See https://github.com/graphql/graphql-spec/pull/793 + */ +class CacheArgumentTest { + @Test + fun variableDefaultValueEmpty() { + val operation = VariableDefaultValueEmptyQuery() + + /** + * One would expect b: 3 here but the default variable is not coerced + */ + assertEquals("a0({\"b\":{}})", operation.fieldKey(VariableDefaultValueEmptyQuery.Data(a0 = 42))) + } + + @Test + fun variableDefaultValueWithC() { + val operation = VariableDefaultValueWithCQuery() + + /** + * The default value contains c + */ + assertEquals("a0({\"b\":{\"c\":4}})", operation.fieldKey(VariableDefaultValueWithCQuery.Data(a0 = 42))) + } + + @Test + fun variableDefaultValueNull() { + val operation = VariableDefaultValueNullQuery() + + /** + * The default value can be null + */ + assertEquals("a0({\"b\":null})", operation.fieldKey(VariableDefaultValueNullQuery.Data(a0 = 42))) + } + + @Test + fun variableAbsent() { + val operation = VariableAbsentQuery() + + /** + * An argument can be absent + */ + assertEquals("a0", operation.fieldKey(VariableAbsentQuery.Data(a0 = 42))) + } + + @Test + fun absentArgumentWithArgumentDefaultValue() { + val operation = AbsentArgumentWithArgumentDefaultValueQuery() + + /** + * The argument definition defaultValue is the empty object and is not coerced + */ + assertEquals("a1", operation.fieldKey(AbsentArgumentWithArgumentDefaultValueQuery.Data(a1 = 42))) + } + + @Test + fun presentArgumentEmpty() { + val operation = PresentArgumentEmptyQuery() + + /** + * Because here we're passing an argument explicitly, this argument is coerced and the 3 default value gets pulled + * in even if it was initially not there + */ + assertEquals("a1({\"b\":{\"c\":3}})", operation.fieldKey(PresentArgumentEmptyQuery.Data(a1 = 42))) + } +} + +private fun Operation.fieldKey(data: D): String { + val record = normalize(data, CustomScalarAdapters.Empty, TypePolicyCacheKeyGenerator) + + return record.values.single().keys.single { it.startsWith("a") } +} diff --git a/tests/cache-variables-arguments/src/commonTest/kotlin/test/IncludeTest.kt b/tests/cache-variables-arguments/src/commonTest/kotlin/test/IncludeTest.kt new file mode 100644 index 00000000..9f709192 --- /dev/null +++ b/tests/cache-variables-arguments/src/commonTest/kotlin/test/IncludeTest.kt @@ -0,0 +1,35 @@ +package test + +import cache.include.GetUserQuery +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.apolloStore +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import kotlin.test.Test +import kotlin.test.assertEquals + +class IncludeTest { + @Test + fun simple() = runTest { + val client = ApolloClient.Builder() + .normalizedCache(MemoryCacheFactory()) + .serverUrl("https://unused.com") + .build() + + val operation = GetUserQuery(withDetails = false) + + val data = GetUserQuery.Data( + user = GetUserQuery.User(__typename = "User", id = "42", userDetails = null) + ) + + client.apolloStore.writeOperation(operation, data) + + val response = client.query(operation).fetchPolicy(FetchPolicy.CacheOnly).execute() + + assertEquals("42", response.data?.user?.id) + assertEquals(null, response.data?.user?.userDetails) + } +} diff --git a/tests/defer/build.gradle.kts b/tests/defer/build.gradle.kts new file mode 100644 index 00000000..5645d05f --- /dev/null +++ b/tests/defer/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + // TODO: Use the external plugin for now - switch to the regular one when Schema is not relocated + // See https://github.com/apollographql/apollo-kotlin/pull/6176 + alias(libs.plugins.apollo.external) +} + +kotlin { + configureKmp( + withJs = true, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) + + sourceSets { + findByName("commonMain")?.apply { + dependencies { + implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + findByName("commonTest")?.apply { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.apollo.testing.support) + implementation(libs.apollo.mockserver) + } + } + } +} + +apollo { + service("base") { + packageName.set("defer") + } +} diff --git a/tests/defer/src/commonMain/graphql/operation.graphql b/tests/defer/src/commonMain/graphql/operation.graphql new file mode 100644 index 00000000..fad24d09 --- /dev/null +++ b/tests/defer/src/commonMain/graphql/operation.graphql @@ -0,0 +1,152 @@ +query WithFragmentSpreadsQuery { + computers { + id + ...ComputerFields @defer + } +} + +fragment ComputerFields on Computer { + cpu + year + screen { + resolution + ...ScreenFields @defer(label: "a") + } +} + +fragment ScreenFields on Screen { + isColor +} + + +query WithInlineFragmentsQuery { + computers { + id + ... on Computer @defer { + cpu + year + screen { + resolution + ... on Screen @defer(label: "b") { + isColor + } + } + } + } +} + + +mutation WithFragmentSpreadsMutation { + computers { + id + ...ComputerFields @defer(label: "c") + } +} + +subscription WithInlineFragmentsSubscription { + count(to: 3) { + value + ... on Counter @defer { + valueTimesTwo + } + } +} + +subscription WithFragmentSpreadsSubscription { + count(to: 3) { + value + ...CounterFields @defer(label: "d") + } +} + +fragment CounterFields on Counter{ + valueTimesTwo +} + +query SimpleDeferQuery { + computers { + id + ... on Computer @defer { + cpu + } + } +} + +query CanDisableDeferUsingIfArgumentQuery { + computers { + id + ... on Computer @defer(if: false) { + cpu + } + } +} + +query DoesNotDisableDeferWithNullIfArgumentQuery($shouldDefer: Boolean) { + computers { + id + ... on Computer @defer(if: $shouldDefer) { + cpu + } + } +} + +query CanDeferFragmentsOnTheTopLevelQueryFieldQuery { + ...FragmentOnQuery @defer +} + +fragment FragmentOnQuery on Query { + computers { + id + } +} + +query CanDeferAFragmentThatIsAlsoNotDeferredDeferredFragmentIsFirstQuery { + computer(id: "Computer1") { + screen { + ...ScreenFields @defer + ...ScreenFields + } + } +} + +query CanDeferAFragmentThatIsAlsoNotDeferredNotDeferredFragmentIsFirstQuery { + computer(id: "Computer1") { + screen { + ...ScreenFields + ...ScreenFields @defer + } + } +} + +query HandlesErrorsThrownInDeferredFragmentsQuery { + computer(id: "Computer1") { + id + ...ComputerErrorField @defer + } +} + +fragment ComputerErrorField on Computer { + errorField +} + +query HandlesNonNullableErrorsThrownInDeferredFragmentsQuery { + computer(id: "Computer1") { + id + ...ComputerNonNullableErrorField @defer + } +} + +fragment ComputerNonNullableErrorField on Computer { + nonNullErrorField +} + +query HandlesNonNullableErrorsThrownOutsideDeferredFragmentsQuery { + computer(id: "Computer1") { + nonNullErrorField + ...ComputerIdField @defer + } +} + +fragment ComputerIdField on Computer { + id +} diff --git a/tests/defer/src/commonMain/graphql/schema.graphqls b/tests/defer/src/commonMain/graphql/schema.graphqls new file mode 100644 index 00000000..0892f0f4 --- /dev/null +++ b/tests/defer/src/commonMain/graphql/schema.graphqls @@ -0,0 +1,31 @@ +type Query { + computers: [Computer!]! + computer(id: ID!): Computer +} + +type Mutation { + computers: [Computer!]! +} + +type Subscription { + count(to: Int!): Counter! +} + +type Counter { + value: Int! + valueTimesTwo: Int! +} + +type Computer { + id: ID! + cpu: String! + year: Int! + screen: Screen! + errorField: String + nonNullErrorField: String! +} + +type Screen { + resolution: String! + isColor: Boolean! +} diff --git a/tests/defer/src/commonTest/kotlin/test/DeferNormalizedCacheTest.kt b/tests/defer/src/commonTest/kotlin/test/DeferNormalizedCacheTest.kt new file mode 100644 index 00000000..7bc97111 --- /dev/null +++ b/tests/defer/src/commonTest/kotlin/test/DeferNormalizedCacheTest.kt @@ -0,0 +1,548 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloRequest +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Error +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.exception.ApolloException +import com.apollographql.apollo.exception.ApolloHttpException +import com.apollographql.apollo.exception.ApolloNetworkException +import com.apollographql.apollo.exception.CacheMissException +import com.apollographql.apollo.network.NetworkTransport +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.CacheHeaders +import com.apollographql.cache.normalized.apolloStore +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.optimisticUpdates +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.assertNoRequest +import com.apollographql.mockserver.awaitRequest +import com.apollographql.mockserver.enqueueError +import com.apollographql.mockserver.enqueueMultipart +import com.apollographql.mockserver.enqueueStrings +import com.benasher44.uuid.uuid4 +import defer.SimpleDeferQuery +import defer.WithFragmentSpreadsMutation +import defer.WithFragmentSpreadsQuery +import defer.fragment.ComputerFields +import defer.fragment.ScreenFields +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import okio.ByteString.Companion.encodeUtf8 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class DeferNormalizedCacheTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore(MemoryCacheFactory()) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private fun tearDown() { + mockServer.close() + apolloClient.close() + } + + @Test + fun cacheOnly() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.CacheOnly).build() + + // Cache is empty + assertIs( + apolloClient.query(WithFragmentSpreadsQuery()).execute().exception + ) + + // Fill the cache by doing a network only request + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"incremental": [{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0]}],"hasNext":true}""", + """{"incremental": [{"data":{"isColor":false},"path":["computers",0,"screen"],"label":"a"}],"hasNext":false}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + apolloClient.query(WithFragmentSpreadsQuery()).fetchPolicy(FetchPolicy.NetworkOnly).toFlow().collect() + mockServer.awaitRequest() + + // Cache is not empty, so this doesn't go to the server + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).execute().dataOrThrow() + mockServer.assertNoRequest() + + // We get the last/fully formed data + val cacheExpected = WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false) + ) + ) + ) + ) + ) + assertEquals(cacheExpected, cacheActual) + } + + @Test + fun networkOnly() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.NetworkOnly).build() + + // Fill the cache by doing a first request + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"incremental": [{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0]}],"hasNext":true}""", + """{"incremental": [{"data":{"isColor":false},"path":["computers",0,"screen"],"label":"a"}],"hasNext":false}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + apolloClient.query(WithFragmentSpreadsQuery()).fetchPolicy(FetchPolicy.NetworkOnly).toFlow().collect() + mockServer.awaitRequest() + + // Cache is not empty, but NetworkOnly still goes to the server + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataOrThrow() } + mockServer.awaitRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null) + ) + ) + ) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false) + ) + ) + ) + ) + ), + ) + assertEquals(networkExpected, networkActual) + } + + @Test + fun cacheFirst() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.CacheFirst).build() + + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"incremental": [{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0]}],"hasNext":true}""", + """{"incremental": [{"data":{"isColor":false},"path":["computers",0,"screen"],"label":"a"}],"hasNext":false}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + + // Cache is empty, so this goes to the server + val responses = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList() + assertTrue(responses[0].exception is CacheMissException) + val networkActual = responses.drop(1).map { it.dataOrThrow() } + mockServer.awaitRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null) + ) + ) + ) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false) + ) + ) + ) + ) + ), + ) + assertEquals(networkExpected, networkActual) + + // Cache is not empty, so this doesn't go to the server + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).execute().dataOrThrow() + assertFails { mockServer.takeRequest() } + + // We get the last/fully formed data + val cacheExpected = networkExpected.last() + assertEquals(cacheExpected, cacheActual) + } + + @Test + fun networkFirst() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.NetworkFirst).build() + + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"incremental": [{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0]}],"hasNext":true}""", + """{"incremental": [{"data":{"isColor":false},"path":["computers",0,"screen"],"label":"a"}],"hasNext":false}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + + // Cache is empty, so this goes to the server + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataOrThrow() } + mockServer.awaitRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null) + ) + ) + ) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false) + ) + ) + ) + ) + ), + ) + assertEquals(networkExpected, networkActual) + + mockServer.enqueueError(statusCode = 500) + // Network will fail, so we get the cached version + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).execute().dataOrThrow() + + // We get the last/fully formed data + val cacheExpected = networkExpected.last() + assertEquals(cacheExpected, cacheActual) + } + + @Test + fun cacheAndNetwork() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.CacheAndNetwork).build() + + val jsonList1 = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"incremental": [{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0]}],"hasNext":true}""", + """{"incremental": [{"data":{"isColor":false},"path":["computers",0,"screen"],"label":"a"}],"hasNext":false}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList1) + + // Cache is empty + val responses = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList() + assertTrue(responses[0].exception is CacheMissException) + val networkActual = responses.drop(1).map { it.dataOrThrow() } + mockServer.awaitRequest() + + val networkExpected = listOf( + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null) + ) + ) + ) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false) + ) + ) + ) + ) + ), + ) + assertEquals(networkExpected, networkActual) + + val jsonList2 = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer2"}]},"hasNext":true}""", + """{"incremental": [{"data":{"cpu":"486","year":1996,"screen":{"__typename":"Screen","resolution":"800x600"}},"path":["computers",0]}],"hasNext":true}""", + """{"incremental": [{"data":{"isColor":true},"path":["computers",0,"screen"],"label":"a"}],"hasNext":false}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList2) + + // Cache is not empty + val cacheAndNetworkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().map { it.dataOrThrow() } + mockServer.awaitRequest() + + // We get a combination of the last/fully formed data from the cache + the new network data + val cacheAndNetworkExpected = listOf( + networkExpected.last(), + + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer2", null)) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer2", ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", null) + ) + ) + ) + ), + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer2", ComputerFields("486", 1996, + ComputerFields.Screen("Screen", "800x600", + ScreenFields(true) + ) + ) + ) + ) + ), + ) + + assertEquals(cacheAndNetworkExpected, cacheAndNetworkActual) + } + + @Test + fun cacheFirstWithMissingFragmentDueToError() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().fetchPolicy(FetchPolicy.CacheFirst).build() + + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"incremental": [{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0]}],"hasNext":true}""", + """{"incremental": [{"data":null,"path":["computers",0,"screen"],"label":"b","errors":[{"message":"Cannot resolve isColor","locations":[{"line":1,"column":119}],"path":["computers",0,"screen","isColor"]}]}],"hasNext":false}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + + // Cache is empty, so this goes to the server + val networkActual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList().drop(1) + mockServer.awaitRequest() + + val query = WithFragmentSpreadsQuery() + val uuid = uuid4() + + val networkExpected = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data(WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ) + ).build(), + + ApolloResponse.Builder( + query, + uuid, + ).data(WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null) + ) + ) + ) + ) + ).build(), + + ApolloResponse.Builder( + query, + uuid, + ) + .data( + WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null) + ) + ) + ) + ) + ) + .errors( + listOf( + Error.Builder(message = "Cannot resolve isColor") + .locations(listOf(Error.Location(1, 119))) + .path(listOf("computers", 0, "screen", "isColor")) + .build() + ) + ) + .build(), + ) + assertResponseListEquals(networkExpected, networkActual) + + mockServer.enqueueError(statusCode = 500) + // Because of the error the cache is missing some fields, so we get a cache miss, and fallback to the network (which also fails) + val exception = apolloClient.query(WithFragmentSpreadsQuery()).execute().exception + check(exception is CacheMissException) + assertIs(exception.suppressedExceptions.first()) + assertEquals("Object 'computers.0.screen' has no field named 'isColor'", exception.message) + mockServer.awaitRequest() + } + + @Test + fun networkFirstWithNetworkError() = runTest(before = { setUp() }, after = { tearDown() }) { + val query = WithFragmentSpreadsQuery() + val uuid = uuid4() + val networkResponses = listOf( + ApolloResponse.Builder( + query, + uuid, + ).data(WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", null)) + ) + ).build(), + + ApolloResponse.Builder( + query, + uuid, + ).data(WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null) + ) + ) + ) + ) + ).build(), + ) + + apolloClient = ApolloClient.Builder() + .store(store) + .fetchPolicy(FetchPolicy.NetworkFirst) + .networkTransport( + object : NetworkTransport { + @Suppress("UNCHECKED_CAST") + override fun execute(request: ApolloRequest): Flow> { + // Emit a few items then an exception + return flow { + for (networkResponse in networkResponses) { + emit(networkResponse as ApolloResponse) + } + delay(10) + emit(ApolloResponse.Builder(requestUuid = uuid, operation = query) + .exception(ApolloNetworkException("Network error")) + .isLast(true) + .build() as ApolloResponse + ) + } + } + + override fun dispose() {} + } + ) + .build() + + // - get the first few responses + // - an exception happens + // - fallback to the cache + // - because of the error the cache is missing some fields, so we get a cache miss + val actual = apolloClient.query(WithFragmentSpreadsQuery()).toFlow().toList() + + assertResponseListEquals(networkResponses, actual.dropLast(2)) + val networkExceptionResponse = actual[actual.size - 2] + val cacheExceptionResponse = actual.last() + assertIs(networkExceptionResponse.exception) + assertIs(cacheExceptionResponse.exception) + assertEquals("Object 'computers.0.screen' has no field named 'isColor'", cacheExceptionResponse.exception!!.message) + } + + @Test + fun mutation() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"incremental": [{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"label":"c"}],"hasNext":true}""", + """{"incremental": [{"data":{"isColor":false},"path":["computers",0,"screen"],"label":"a"}],"hasNext":false}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + val networkActual = apolloClient.mutation(WithFragmentSpreadsMutation()).toFlow().toList().map { it.dataOrThrow() } + mockServer.awaitRequest() + + val networkExpected = listOf( + WithFragmentSpreadsMutation.Data( + listOf(WithFragmentSpreadsMutation.Computer("Computer", "Computer1", null)) + ), + WithFragmentSpreadsMutation.Data( + listOf(WithFragmentSpreadsMutation.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", null) + ) + ) + ) + ), + WithFragmentSpreadsMutation.Data( + listOf(WithFragmentSpreadsMutation.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false) + ) + ) + ) + ) + ), + ) + assertEquals(networkExpected, networkActual) + + // Now cache is not empty + val cacheActual = apolloClient.query(WithFragmentSpreadsQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute().dataOrThrow() + + // We get the last/fully formed data + val cacheExpected = WithFragmentSpreadsQuery.Data( + listOf(WithFragmentSpreadsQuery.Computer("Computer", "Computer1", ComputerFields("386", 1993, + ComputerFields.Screen("Screen", "640x480", + ScreenFields(false) + ) + ) + ) + ) + ) + assertEquals(cacheExpected, cacheActual) + } + + @Test + fun mutationWithOptimisticDataFails() = runTest(before = { setUp() }, after = { tearDown() }) { + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"incremental": [{"data":{"cpu":"386","year":1993,"screen":{"__typename":"Screen","resolution":"640x480"}},"path":["computers",0],"label":"c"}],"hasNext":true}""", + """{"incremental": [{"data":{"isColor":false},"path":["computers",0,"screen"],"label":"a"}],"hasNext":false}""", + ) + mockServer.enqueueMultipart("application/json").enqueueStrings(jsonList) + val responses = apolloClient.mutation(WithFragmentSpreadsMutation()).optimisticUpdates( + WithFragmentSpreadsMutation.Data( + listOf(WithFragmentSpreadsMutation.Computer("Computer", "Computer1", null)) + ) + ).toFlow() + + val exception = assertFailsWith { + responses.collect() + } + assertEquals("Apollo: optimistic updates can only be applied with one network response", exception.message) + } + + @Test + fun intermediatePayloadsAreCached() = runTest(before = { setUp() }, after = { tearDown() }) { + @Suppress("DEPRECATION") + if (com.apollographql.apollo.testing.platform() == com.apollographql.apollo.testing.Platform.Js) { + // TODO For now chunked is not supported on JS - remove this check when it is + return@runTest + } + val jsonList = listOf( + """{"data":{"computers":[{"__typename":"Computer","id":"Computer1"}]},"hasNext":true}""", + """{"incremental": [{"data":{"cpu":"386"},"path":["computers",0]}],"hasNext":false}""", + ) + val multipartBody = mockServer.enqueueMultipart("application/json") + multipartBody.enqueuePart(jsonList[0].encodeUtf8(), false) + val recordFields = apolloClient.query(SimpleDeferQuery()).fetchPolicy(FetchPolicy.NetworkOnly).toFlow().map { + apolloClient.apolloStore.accessCache { it.loadRecord("computers.0", CacheHeaders.NONE)!!.fields }.also { + multipartBody.enqueuePart(jsonList[1].encodeUtf8(), true) + } + }.toList() + assertEquals(mapOf("__typename" to "Computer", "id" to "Computer1"), recordFields[0]) + assertEquals(mapOf("__typename" to "Computer", "id" to "Computer1", "cpu" to "386"), recordFields[1]) + } +} diff --git a/tests/defer/src/commonTest/kotlin/test/TestUtil.kt b/tests/defer/src/commonTest/kotlin/test/TestUtil.kt new file mode 100644 index 00000000..35c33053 --- /dev/null +++ b/tests/defer/src/commonTest/kotlin/test/TestUtil.kt @@ -0,0 +1,28 @@ +package test + +import com.apollographql.apollo.api.ApolloResponse +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +internal fun assertResponseListEquals(expectedResponseList: List>, actualResponseList: List>) { + assertContentEquals(expectedResponseList, actualResponseList) { expectedResponse, actualResponse -> + assertEquals(expectedResponse.data, actualResponse.data) + assertContentEquals(expectedResponse.errors, actualResponse.errors) { expectedError, actualError -> + assertEquals(expectedError.message, actualError.message) + kotlin.test.assertContentEquals(expectedError.path, actualError.path) + } + } +} + +internal fun assertContentEquals(expected: List?, actual: List?, assertEquals: (T, T) -> Unit) { + if (expected == null) { + assertNull(actual) + return + } + assertNotNull(actual) + assertEquals(expected.size, actual.size) + for (i in expected.indices) { + assertEquals(expected[i], actual[i]) + } +} diff --git a/tests/include-skip-operation-based/build.gradle.kts b/tests/include-skip-operation-based/build.gradle.kts new file mode 100644 index 00000000..2cb4e8d2 --- /dev/null +++ b/tests/include-skip-operation-based/build.gradle.kts @@ -0,0 +1,42 @@ +import com.apollographql.apollo.annotations.ApolloExperimental + +plugins { + alias(libs.plugins.kotlin.multiplatform) + // TODO: Use the external plugin for now - switch to the regular one when Schema is not relocated + // See https://github.com/apollographql/apollo-kotlin/pull/6176 + alias(libs.plugins.apollo.external) +} + +kotlin { + configureKmp( + withJs = true, + withWasm = true, + withAndroid = false, + withApple = AppleTargets.Host, + ) + + sourceSets { + findByName("commonMain")?.apply { + dependencies { + implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + findByName("commonTest")?.apply { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.apollo.testing.support) + implementation(libs.apollo.mockserver) + } + } + } +} + +apollo { + service("service") { + packageName.set("com.example") + @OptIn(ApolloExperimental::class) + generateDataBuilders.set(true) + } +} diff --git a/tests/include-skip-operation-based/src/commonMain/graphql/operations.graphql b/tests/include-skip-operation-based/src/commonMain/graphql/operations.graphql new file mode 100644 index 00000000..a94e10aa --- /dev/null +++ b/tests/include-skip-operation-based/src/commonMain/graphql/operations.graphql @@ -0,0 +1,69 @@ +query GetCatIncludeVariable($withCat: Boolean!) { + animal { + ... on Cat @include(if: $withCat) { + meow + } + } +} + +query GetCatIncludeFalse { + animal { + ... on Cat @include(if: false) { + meow + } + } +} + +query GetCatIncludeTrue { + animal { + ... on Cat @include(if: true) { + meow + } + } +} + + +query GetDogSkipVariable($withoutDog: Boolean!) { + animal { + ...dogFragment @skip(if: $withoutDog) + } +} + +query GetDogSkipTrue { + animal { + ...dogFragment @skip(if: true) + } +} + +query GetDogSkipFalse { + animal { + ...dogFragment @skip(if: false) + } +} + +fragment dogFragment on Dog { + barf +} + + +query GetCatIncludeVariableWithDefault($skipIf: Boolean = true) { + animal { + species @skip(if: $skipIf) + } +} + +fragment animalFragment on Animal { + species @skip(if: $skipIf) +} + +query SkipInFragment($skipIf: Boolean = true) { + animal { + ...animalFragment + } +} + +query SkipFragmentWithDefaultToFalse($skipIf: Boolean = false) { + animal { + ...dogFragment @skip(if: $skipIf) + } +} diff --git a/tests/include-skip-operation-based/src/commonMain/graphql/schema.graphqls b/tests/include-skip-operation-based/src/commonMain/graphql/schema.graphqls new file mode 100644 index 00000000..1b191ce4 --- /dev/null +++ b/tests/include-skip-operation-based/src/commonMain/graphql/schema.graphqls @@ -0,0 +1,18 @@ +type Query { + animal: Animal +} + +interface Animal { + species: String! +} + +type Cat implements Animal { + species: String + meow: String + mustaches: Int +} + +type Dog implements Animal { + species: String + barf: String +} diff --git a/tests/include-skip-operation-based/src/commonTest/kotlin/IncludeTest.kt b/tests/include-skip-operation-based/src/commonTest/kotlin/IncludeTest.kt new file mode 100644 index 00000000..4c04a76a --- /dev/null +++ b/tests/include-skip-operation-based/src/commonTest/kotlin/IncludeTest.kt @@ -0,0 +1,49 @@ +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.api.Optional +import com.apollographql.apollo.api.json.MapJsonReader +import com.apollographql.apollo.api.toApolloResponse +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.api.TypePolicyCacheKeyGenerator +import com.apollographql.cache.normalized.api.normalize +import com.example.GetCatIncludeVariableWithDefaultQuery +import com.example.SkipFragmentWithDefaultToFalseQuery +import com.example.type.buildCat +import com.example.type.buildDog +import kotlin.test.Test +import kotlin.test.assertNull + +class IncludeTest { + private fun Operation.parseData(data: Map): ApolloResponse { + return MapJsonReader(mapOf("data" to data)).toApolloResponse(this) + } + + @Test + fun getCatIncludeVariableWithDefaultQuery() = runTest { + val operation = GetCatIncludeVariableWithDefaultQuery() + + val data = GetCatIncludeVariableWithDefaultQuery.Data { + animal = buildCat { + this["species"] = Optional.Absent + } + } + + val normalized = operation.normalize(data, CustomScalarAdapters.Empty, TypePolicyCacheKeyGenerator) + assertNull((normalized["animal"] as Map<*, *>)["species"]) + } + + @Test + fun skipFragmentWithDefaultToFalseQuery2() = runTest { + val operation = SkipFragmentWithDefaultToFalseQuery() + + val data = SkipFragmentWithDefaultToFalseQuery.Data { + animal = buildDog { + barf = "ouaf" + } + } + + val normalized = operation.normalize(data, CustomScalarAdapters.Empty, TypePolicyCacheKeyGenerator) + assertNull((normalized["animal"] as Map<*, *>)["barf"]) + } +} diff --git a/tests/kotlin-js-store/yarn.lock b/tests/kotlin-js-store/yarn.lock new file mode 100644 index 00000000..f131b1d0 --- /dev/null +++ b/tests/kotlin-js-store/yarn.lock @@ -0,0 +1,554 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +abort-controller@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +ansi-colors@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +binary-extensions@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-stdout@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +camelcase@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@^3.5.3: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +debug@^4.3.5: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +diff@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +format-util@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" + integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +minimatch@^5.0.1, minimatch@^5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +mocha@10.7.0: + version "10.7.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.7.0.tgz#9e5cbed8fa9b37537a25bd1f7fb4f6fc45458b9a" + integrity sha512-v8/rBWr2VO5YkspYINnvu81inSz2y3ODJrhO175/Exzor1RcEZZkizgE2A+w/CAXXoESS8Kys5E62dOHGHzULA== + dependencies: + ansi-colors "^4.1.3" + browser-stdout "^1.3.1" + chokidar "^3.5.3" + debug "^4.3.5" + diff "^5.2.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^8.1.0" + he "^1.2.0" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^5.1.6" + ms "^2.1.3" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^6.5.1" + yargs "^16.2.0" + yargs-parser "^20.2.9" + yargs-unparser "^2.0.0" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +node-fetch@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +safe-buffer@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + +source-map-support@0.5.21: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +typescript@5.5.4: + version "5.5.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" + integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +workerpool@^6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" + integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +ws@8.5.0: + version "8.5.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f" + integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^20.2.2, yargs-parser@^20.2.9: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-unparser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/tests/models-fixtures/graphql/operations.graphql b/tests/models-fixtures/graphql/operations.graphql new file mode 100644 index 00000000..f487c33b --- /dev/null +++ b/tests/models-fixtures/graphql/operations.graphql @@ -0,0 +1,153 @@ +query HeroAndFriendsWithFragments { + hero { + ... HeroWithFriendsFragment + } +} + +fragment HeroWithFriendsFragment on Character { + id + name + friends { + ... HumanWithIdFragment + } +} + +fragment HumanWithIdFragment on Human { + id + name +} + +query HeroAndFriendsNamesWithIDs($episode: Episode) { + hero(episode: $episode) { + id + name + friends { + id + name + } + } +} + +query HeroAndFriendsWithTypename { + hero { + __typename + id + name + friends { + __typename + id + name + } + } +} + + + +query AllPlanets { + allPlanets(first: 300) { + planets { + ...PlanetFragment + filmConnection { + totalCount + films { + title + ...FilmFragment + } + } + } + } +} + +query HeroParentTypeDependentField($episode: Episode) { + hero(episode: $episode) { + name + ... on Human { + friends { + name + ... on Human { + height(unit: FOOT) + } + } + } + ... on Droid { + friends { + name + ... on Human { + height(unit: METER) + } + } + } + } +} + +query Birthdate { + hero { + birthDate + } +} + +query Episode { + hero { + appearsIn + } +} + +query MergedFieldWithSameShape($episode: Episode) { + hero(episode: $episode) { + ... on Human { + property: homePlanet + } + ... on Droid { + property: primaryFunction + } + } +} + + +fragment FilmFragment on Film { + title + producers +} + +fragment PlanetFragment on Planet { + name + climates + surfaceWater +} + +query HeroHumanOrDroid($episode: Episode) { + hero(episode: $episode) { + name + ... on Human { + homePlanet + } + ... on Droid { + primaryFunction + } + } +} + +query Starship($id: ID!) { + starship(id: $id) { + id + name + starshipType + } + + aFieldWithAVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryVeryLongName: starship(id: $id) { + id + } +} + + +fragment NamedFragment on Human { + name +} +query InlineAndNamedFragment { + hero { + ...NamedFragment + ... on Droid { + primaryFunction + } + } +} diff --git a/tests/models-fixtures/graphql/schema.graphqls b/tests/models-fixtures/graphql/schema.graphqls new file mode 100644 index 00000000..1f227f08 --- /dev/null +++ b/tests/models-fixtures/graphql/schema.graphqls @@ -0,0 +1,535 @@ +""" +The query type, represents all of the entry points into our object graph +""" +type Query { + + hero ( episode: Episode): Character + + heroDetailQuery: Character + + heroWithReview ( episode: Episode, review: ReviewInput): Human + + reviews ( episode: Episode!): [Review] + + search ( text: String): [SearchResult] + + character ( id: ID!): Character + + droid ( id: ID!): Droid + + human ( id: ID!): Human + + starship ( id: ID!): Starship + + allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection +} + +""" +The episodes in the Star Wars trilogy +""" +enum Episode { + """ + Star Wars Episode IV: A New Hope, released in 1977. + """ + NEWHOPE + """ + Star Wars Episode V: The Empire Strikes Back, released in 1980. + """ + EMPIRE + """ + Star Wars Episode VI: Return of the Jedi, released in 1983. + """ + JEDI +} + +""" +A character from the Star Wars universe +""" +interface Character { + """ + The ID of the character + """ + id: ID! + """ + The name of the character + """ + name: String! + """ + The friends of the character, or an empty list if they have none + """ + friends: [Character] + """ + The friends of the character exposed as a connection with edges + """ + friendsConnection ( first: Int, after: ID): FriendsConnection! + """ + The movies this character appears in + """ + appearsIn: [Episode]! + """ + The movie this character first appears in + """ + firstAppearsIn: Episode! + """ + The date character was born. + """ + birthDate: Date! + """ + The date character was born. + """ + fieldWithUnsupportedType: UnsupportedType! + """ + The dates of appearances + """ + appearanceDates: [Date!]! +} + +""" +The `Date` scalar type represents date format. +""" +scalar Date +""" +UnsupportedType for testing +""" +scalar UnsupportedType +""" +A connection object for a character's friends +""" +type FriendsConnection { + """ + The total number of friends + """ + totalCount: Int + """ + The edges for each of the character's friends. + """ + edges: [FriendsEdge] + """ + A list of the friends, as a convenience when edges are not needed. + """ + friends: [Character] + """ + Information for paginating this connection + """ + pageInfo: PageInfo! +} + +""" +An edge object for a character's friends +""" +type FriendsEdge { + """ + A cursor used for pagination + """ + cursor: ID! + """ + The character represented by this friendship edge + """ + node: Character +} + +""" +Information for paginating this connection +""" +type PageInfo { + + startCursor: ID + + endCursor: ID + + hasNextPage: Boolean! +} + +""" +Represents a review for a movie +""" +type Review { + """ + The ID of the review + """ + id: ID! + """ + The number of stars this review gave, 1-5 + """ + stars: Int! + """ + Comment about the movie + """ + commentary: String +} + + +union SearchResult = Human|Droid|Starship +""" +A humanoid creature from the Star Wars universe +""" +type Human implements Character { + """ + The ID of the human + """ + id: ID! + """ + What this human calls themselves + """ + name: String! + """ + The home planet of the human, or null if unknown + """ + homePlanet: String + """ + Height in the preferred unit, default is meters + """ + height ( unit: LengthUnit = METER): Float + """ + Mass in kilograms, or null if unknown + """ + mass: Float + """ + This human's friends, or an empty list if they have none + """ + friends: [Character] + """ + The friends of the human exposed as a connection with edges + """ + friendsConnection ( first: Int, after: ID): FriendsConnection! + """ + The movies this human appears in + """ + appearsIn: [Episode]! + """ + The movie this character first appears in + """ + firstAppearsIn: Episode! + """ + The date character was born. + """ + birthDate: Date! + """ + The date character was born. + """ + fieldWithUnsupportedType: UnsupportedType! + """ + The dates of appearances + """ + appearanceDates: [Date!]! + """ + A list of starships this person has piloted, or an empty list if none + """ + starships: [Starship] +} + +""" +Units of height +""" +enum LengthUnit { + """ + The standard unit around the world + """ + METER + """ + Primarily used in the United States + """ + FOOT +} + + +type Starship { + """ + The ID of the starship + """ + id: ID! + """ + The name of the starship + """ + name: String! + """ + Length of the starship, along the longest axis + """ + length ( unit: LengthUnit = METER): Float + + coordinates: [[Float!]!] + + starshipType: StarshipType +} + +enum StarshipType { + BATTLE_CRUISER + COMBAT_CRUISER + STAR_CRUISER +} + +""" +An autonomous mechanical character in the Star Wars universe +""" +type Droid implements Character { + """ + The ID of the droid + """ + id: ID! + """ + What others call this droid + """ + name: String! + """ + This droid's friends, or an empty list if they have none + """ + friends: [Character] + """ + The friends of the droid exposed as a connection with edges + """ + friendsConnection ( first: Int, after: ID): FriendsConnection! + """ + The movies this droid appears in + """ + appearsIn: [Episode]! + """ + The movie this character first appears in + """ + firstAppearsIn: Episode! + """ + The date droid was created. + """ + birthDate: Date! + """ + The date character was born. + """ + fieldWithUnsupportedType: UnsupportedType! + """ + The dates of appearances + """ + appearanceDates: [Date!]! + """ + This droid's primary function + """ + primaryFunction: String +} + +""" +The mutation type, represents all updates we can make to our data +""" +type Mutation { + + createReview ( episode: Episode, review: ReviewInput!): Review + + updateReview ( id: ID!, review: ReviewInput!): Review +} + +""" +The input object sent when someone is creating a new review +""" +input ReviewInput { + """ + 0-5 stars + """ stars: Int! + """ + Comment about the movie, optional + """ commentary: String + """ + Favorite color, optional + """ favoriteColor: ColorInput! +} + +""" +The input object sent when passing in a color +""" +input ColorInput { + red: Int! = 1 + green: Float = 0.0 + blue: Float! = 1.5 +} + + +""" +A connection to a list of items. +""" +type PlanetsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [PlanetsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] +} + +""" +An edge in a connection. +""" +type PlanetsEdge { + """ + The item at the end of the edge + """ + node: Planet + """ + A cursor for use in pagination + """ + cursor: String! +} + + +""" +A large mass, planet or planetoid in the Star Wars Universe, at the time of +0 ABY. +""" +type Planet { + """ + The name of this planet. + """ + name: String + """ + The diameter of this planet in kilometers. + """ + diameter: Int + """ + The number of standard hours it takes for this planet to complete a single + rotation on its axis. + """ + rotationPeriod: Int + """ + The number of standard days it takes for this planet to complete a single orbit + of its local star. + """ + orbitalPeriod: Int + """ + A number denoting the gravity of this planet, where "1" is normal or 1 standard + G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. + """ + gravity: String + """ + The average population of sentient beings inhabiting this planet. + """ + population: Int + """ + The climates of this planet. + """ + climates: [String] + """ + The terrains of this planet. + """ + terrains: [String] + """ + The percentage of the planet surface that is naturally occuring water or bodies + of water. + """ + surfaceWater: Float + filmConnection(after: String, first: Int, before: String, last: Int): PlanetFilmsConnection + """ + The ISO 8601 date format of the time that this resource was created. + """ + created: String + """ + The ISO 8601 date format of the time that this resource was edited. + """ + edited: String + """ + The ID of an object + """ + id: ID! +} + + + +""" +A connection to a list of items. +""" +type PlanetFilmsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [PlanetFilmsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +""" +An edge in a connection. +""" +type PlanetFilmsEdge { + """ + The item at the end of the edge + """ + node: Film + """ + A cursor for use in pagination + """ + cursor: String! +} + + +""" +A single film. +""" +type Film { + """ + The title of this film. + """ + title: String + """ + The episode number of this film. + """ + episodeID: Int + """ + The opening paragraphs at the beginning of this film. + """ + openingCrawl: String + """ + The name of the director of this film. + """ + director: String + """ + The name(s) of the producer(s) of this film. + """ + producers: [String] + """ + The ISO 8601 date format of film release at original creator country. + """ + releaseDate: Date! + planetConnection(after: String, first: Int, before: String, last: Int): PlanetsConnection + """ + The ISO 8601 date format of the time that this resource was created. + """ + created: String + """ + The ISO 8601 date format of the time that this resource was edited. + """ + edited: String + """ + The ID of an object + """ + id: ID! +} diff --git a/tests/models-fixtures/json/AllPlanets.json b/tests/models-fixtures/json/AllPlanets.json new file mode 100644 index 00000000..78c2b1e5 --- /dev/null +++ b/tests/models-fixtures/json/AllPlanets.json @@ -0,0 +1,1073 @@ +{ + "data": { + "allPlanets": { + "__typename": "PlanetsConnection", + "planets": [ + { + "__typename": "Planet", + "name": "Tatooine", + "climates": [ + "arid" + ], + "surfaceWater": 1, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 5, + "films": [ + { + "__typename": "Film", + "title": "A New Hope", + "producers": [ + "Gary Kurtz", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "The Phantom Menace", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Alderaan", + "climates": [ + "temperate" + ], + "surfaceWater": 40, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 2, + "films": [ + { + "__typename": "Film", + "title": "A New Hope", + "producers": [ + "Gary Kurtz", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Yavin IV", + "climates": [ + "temperate", + "tropical" + ], + "surfaceWater": 8, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "A New Hope", + "producers": [ + "Gary Kurtz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Hoth", + "climates": [ + "frozen" + ], + "surfaceWater": 100, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Dagobah", + "climates": [ + "murky" + ], + "surfaceWater": 8, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 3, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Bespin", + "climates": [ + "temperate" + ], + "surfaceWater": 0, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Endor", + "climates": [ + "temperate" + ], + "surfaceWater": 8, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Naboo", + "climates": [ + "temperate" + ], + "surfaceWater": 12, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 4, + "films": [ + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "The Phantom Menace", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Coruscant", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 4, + "films": [ + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "The Phantom Menace", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Kamino", + "climates": [ + "temperate" + ], + "surfaceWater": 100, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Geonosis", + "climates": [ + "temperate", + "arid" + ], + "surfaceWater": 5, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Utapau", + "climates": [ + "temperate", + "arid", + "windy" + ], + "surfaceWater": 0.9, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Mustafar", + "climates": [ + "hot" + ], + "surfaceWater": 0, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Kashyyyk", + "climates": [ + "tropical" + ], + "surfaceWater": 60, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Polis Massa", + "climates": [ + "artificial temperate" + ], + "surfaceWater": 0, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Mygeeto", + "climates": [ + "frigid" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Felucia", + "climates": [ + "hot", + "humid" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Cato Neimoidia", + "climates": [ + "temperate", + "moist" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Saleucami", + "climates": [ + "hot" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Stewjon", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Eriadu", + "climates": [ + "polluted" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Corellia", + "climates": [ + "temperate" + ], + "surfaceWater": 70, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Rodia", + "climates": [ + "hot" + ], + "surfaceWater": 60, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Nal Hutta", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Dantooine", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Bestine IV", + "climates": [ + "temperate" + ], + "surfaceWater": 98, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Ord Mantell", + "climates": [ + "temperate" + ], + "surfaceWater": 10, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "unknown", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Trandosha", + "climates": [ + "arid" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Socorro", + "climates": [ + "arid" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Mon Cala", + "climates": [ + "temperate" + ], + "surfaceWater": 100, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Chandrila", + "climates": [ + "temperate" + ], + "surfaceWater": 40, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Sullust", + "climates": [ + "superheated" + ], + "surfaceWater": 5, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Toydaria", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Malastare", + "climates": [ + "arid", + "temperate", + "tropical" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Dathomir", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Ryloth", + "climates": [ + "temperate", + "arid", + "subartic" + ], + "surfaceWater": 5, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Aleen Minor", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Vulpter", + "climates": [ + "temperate", + "artic" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Troiken", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Tund", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Haruun Kal", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Cerea", + "climates": [ + "temperate" + ], + "surfaceWater": 20, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Glee Anselm", + "climates": [ + "tropical", + "temperate" + ], + "surfaceWater": 80, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Iridonia", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Tholoth", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Iktotch", + "climates": [ + "arid", + "rocky", + "windy" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Quermia", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Dorin", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Champala", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Mirial", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Serenno", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Concord Dawn", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Zolan", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Ojom", + "climates": [ + "frigid" + ], + "surfaceWater": 100, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Skako", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Muunilinst", + "climates": [ + "temperate" + ], + "surfaceWater": 25, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Shili", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Kalee", + "climates": [ + "arid", + "temperate", + "tropical" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Umbara", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + } + ] + } + } +} \ No newline at end of file diff --git a/tests/models-fixtures/json/HeroAndFriendsNamesWithIDs.json b/tests/models-fixtures/json/HeroAndFriendsNamesWithIDs.json new file mode 100644 index 00000000..9fa71b22 --- /dev/null +++ b/tests/models-fixtures/json/HeroAndFriendsNamesWithIDs.json @@ -0,0 +1,26 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friends": [ + { + "__typename": "Human", + "id": "1000", + "name": "Luke Skywalker" + }, + { + "__typename": "Human", + "id": "1002", + "name": "Han Solo" + }, + { + "__typename": "Human", + "id": "1003", + "name": "Leia Organa" + } + ] + } + } +} diff --git a/tests/models-fixtures/json/HeroAndFriendsWithTypename.json b/tests/models-fixtures/json/HeroAndFriendsWithTypename.json new file mode 100644 index 00000000..9fa71b22 --- /dev/null +++ b/tests/models-fixtures/json/HeroAndFriendsWithTypename.json @@ -0,0 +1,26 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friends": [ + { + "__typename": "Human", + "id": "1000", + "name": "Luke Skywalker" + }, + { + "__typename": "Human", + "id": "1002", + "name": "Han Solo" + }, + { + "__typename": "Human", + "id": "1003", + "name": "Leia Organa" + } + ] + } + } +} diff --git a/tests/models-fixtures/json/HeroHumanOrDroid.json b/tests/models-fixtures/json/HeroHumanOrDroid.json new file mode 100644 index 00000000..610e2f30 --- /dev/null +++ b/tests/models-fixtures/json/HeroHumanOrDroid.json @@ -0,0 +1,9 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "name": "R2-D2", + "primaryFunction": "Astromech" + } + } +} diff --git a/tests/models-fixtures/json/HeroParentTypeDependentField.json b/tests/models-fixtures/json/HeroParentTypeDependentField.json new file mode 100644 index 00000000..ba414a2f --- /dev/null +++ b/tests/models-fixtures/json/HeroParentTypeDependentField.json @@ -0,0 +1,25 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "name": "R2-D2", + "friends": [ + { + "__typename": "Human", + "name": "Luke Skywalker", + "height": 1.72 + }, + { + "__typename": "Human", + "name": "Han Solo", + "height": 1.8 + }, + { + "__typename": "Human", + "name": "Leia Organa", + "height": 1.5 + } + ] + } + } +} diff --git a/tests/models-fixtures/json/MergedFieldWithSameShape_Droid.json b/tests/models-fixtures/json/MergedFieldWithSameShape_Droid.json new file mode 100644 index 00000000..c4abfde0 --- /dev/null +++ b/tests/models-fixtures/json/MergedFieldWithSameShape_Droid.json @@ -0,0 +1,8 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "property": "Astromech" + } + } +} diff --git a/tests/models-fixtures/json/MergedFieldWithSameShape_Human.json b/tests/models-fixtures/json/MergedFieldWithSameShape_Human.json new file mode 100644 index 00000000..2690b25e --- /dev/null +++ b/tests/models-fixtures/json/MergedFieldWithSameShape_Human.json @@ -0,0 +1,8 @@ +{ + "data": { + "hero": { + "__typename": "Human", + "property": "Tatooine" + } + } +} diff --git a/tests/models-fixtures/json/OperationJsonWriter.json b/tests/models-fixtures/json/OperationJsonWriter.json new file mode 100644 index 00000000..127745f6 --- /dev/null +++ b/tests/models-fixtures/json/OperationJsonWriter.json @@ -0,0 +1,968 @@ +{ + "data": { + "allPlanets": { + "planets": [ + { + "__typename": "Planet", + "name": "Tatooine", + "climates": null, + "surfaceWater": 1.0, + "filmConnection": null + }, + { + "__typename": "Planet", + "name": "Alderaan", + "climates": [ + "temperate" + ], + "surfaceWater": 40.0, + "filmConnection": { + "totalCount": 2, + "films": [ + { + "__typename": "Film", + "title": "A New Hope", + "producers": [ + "Gary Kurtz", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Yavin IV", + "climates": [ + "temperate", + "tropical" + ], + "surfaceWater": 8.0, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "A New Hope", + "producers": [ + "Gary Kurtz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Hoth", + "climates": [ + "frozen" + ], + "surfaceWater": 100.0, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Dagobah", + "climates": [ + "murky" + ], + "surfaceWater": 8.0, + "filmConnection": { + "totalCount": 3, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Bespin", + "climates": [ + "temperate" + ], + "surfaceWater": 0.0, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Endor", + "climates": [ + "temperate" + ], + "surfaceWater": 8.0, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Naboo", + "climates": [ + "temperate" + ], + "surfaceWater": 12.0, + "filmConnection": { + "totalCount": 4, + "films": [ + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "The Phantom Menace", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Coruscant", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 4, + "films": [ + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "The Phantom Menace", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Kamino", + "climates": [ + "temperate" + ], + "surfaceWater": 100.0, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Geonosis", + "climates": [ + "temperate", + "arid" + ], + "surfaceWater": 5.0, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Utapau", + "climates": [ + "temperate", + "arid", + "windy" + ], + "surfaceWater": 0.9, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Mustafar", + "climates": [ + "hot" + ], + "surfaceWater": 0.0, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Kashyyyk", + "climates": [ + "tropical" + ], + "surfaceWater": 60.0, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Polis Massa", + "climates": [ + "artificial temperate" + ], + "surfaceWater": 0.0, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Mygeeto", + "climates": [ + "frigid" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Felucia", + "climates": [ + "hot", + "humid" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Cato Neimoidia", + "climates": [ + "temperate", + "moist" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Saleucami", + "climates": [ + "hot" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Stewjon", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Eriadu", + "climates": [ + "polluted" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Corellia", + "climates": [ + "temperate" + ], + "surfaceWater": 70.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Rodia", + "climates": [ + "hot" + ], + "surfaceWater": 60.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Nal Hutta", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Dantooine", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Bestine IV", + "climates": [ + "temperate" + ], + "surfaceWater": 98.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Ord Mantell", + "climates": [ + "temperate" + ], + "surfaceWater": 10.0, + "filmConnection": { + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "unknown", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Trandosha", + "climates": [ + "arid" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Socorro", + "climates": [ + "arid" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Mon Cala", + "climates": [ + "temperate" + ], + "surfaceWater": 100.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Chandrila", + "climates": [ + "temperate" + ], + "surfaceWater": 40.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Sullust", + "climates": [ + "superheated" + ], + "surfaceWater": 5.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Toydaria", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Malastare", + "climates": [ + "arid", + "temperate", + "tropical" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Dathomir", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Ryloth", + "climates": [ + "temperate", + "arid", + "subartic" + ], + "surfaceWater": 5.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Aleen Minor", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Vulpter", + "climates": [ + "temperate", + "artic" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Troiken", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Tund", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Haruun Kal", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Cerea", + "climates": [ + "temperate" + ], + "surfaceWater": 20.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Glee Anselm", + "climates": [ + "tropical", + "temperate" + ], + "surfaceWater": 80.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Iridonia", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Tholoth", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Iktotch", + "climates": [ + "arid", + "rocky", + "windy" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Quermia", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Dorin", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Champala", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Mirial", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Serenno", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Concord Dawn", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Zolan", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Ojom", + "climates": [ + "frigid" + ], + "surfaceWater": 100.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Skako", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Muunilinst", + "climates": [ + "temperate" + ], + "surfaceWater": 25.0, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Shili", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Kalee", + "climates": [ + "arid", + "temperate", + "tropical" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Umbara", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "totalCount": 0, + "films": [] + } + } + ] + } + } +} \ No newline at end of file diff --git a/tests/models-operation-based-with-interfaces/build.gradle.kts b/tests/models-operation-based-with-interfaces/build.gradle.kts new file mode 100644 index 00000000..bee92ce3 --- /dev/null +++ b/tests/models-operation-based-with-interfaces/build.gradle.kts @@ -0,0 +1,43 @@ +import com.apollographql.apollo.annotations.ApolloExperimental + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.apollo) +} + +kotlin { + configureKmp( + withJs = true, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) + + sourceSets { + getByName("commonMain") { + dependencies { + implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + getByName("commonTest") { + dependencies { + implementation(libs.apollo.testing.support) + implementation(libs.apollo.mockserver) + implementation(libs.kotlin.test) + } + } + } +} + +apollo { + service("fixtures") { + srcDir(file("../models-fixtures/graphql")) + packageName.set("codegen.models") + @OptIn(ApolloExperimental::class) + generateDataBuilders.set(true) + generateFragmentImplementations.set(true) + codegenModels.set("experimental_operationBasedWithInterfaces") + } +} diff --git a/tests/models-operation-based-with-interfaces/src/commonTest/kotlin/Utils.kt b/tests/models-operation-based-with-interfaces/src/commonTest/kotlin/Utils.kt new file mode 100644 index 00000000..37d2c960 --- /dev/null +++ b/tests/models-operation-based-with-interfaces/src/commonTest/kotlin/Utils.kt @@ -0,0 +1,4 @@ +import com.apollographql.apollo.testing.pathToUtf8 + +@Suppress("DEPRECATION") +fun testFixtureToUtf8(name: String) = pathToUtf8("models-fixtures/json/$name") diff --git a/tests/models-operation-based-with-interfaces/src/commonTest/kotlin/test/BasicTest.kt b/tests/models-operation-based-with-interfaces/src/commonTest/kotlin/test/BasicTest.kt new file mode 100644 index 00000000..2ad98f9b --- /dev/null +++ b/tests/models-operation-based-with-interfaces/src/commonTest/kotlin/test/BasicTest.kt @@ -0,0 +1,89 @@ +package test + +import codegen.models.HeroParentTypeDependentFieldQuery +import codegen.models.MergedFieldWithSameShapeQuery +import codegen.models.type.Episode +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Optional +import com.apollographql.apollo.api.Query +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import testFixtureToUtf8 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BasicTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = IdCacheKeyGenerator() + ) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + private fun basicTest(resourceName: String, query: Query, block: ApolloResponse.() -> Unit) = + runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8(resourceName)) + var response = apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + response.block() + response = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute() + response.block() + } + + @Test + @Throws(Exception::class) + fun heroParentTypeDependentField() = basicTest( + "HeroParentTypeDependentField.json", + HeroParentTypeDependentFieldQuery(Optional.Present(Episode.NEWHOPE)) + ) { + + assertFalse(hasErrors()) + assertEquals(data?.hero?.name, "R2-D2") + assertEquals(data?.hero?.name, "R2-D2") + val hero = data?.hero?.onDroid!! + assertEquals(hero.friends?.size, 3) + assertEquals(hero.friends?.get(0)?.name, "Luke Skywalker") + assertEquals(hero.friends?.get(0)?.name, "Luke Skywalker") + assertEquals((hero.friends?.get(0)?.onHuman)?.height, 1.72) + } + + + @Test + fun polymorphicDroidFieldsGetParsedToDroid() = basicTest( + "MergedFieldWithSameShape_Droid.json", + MergedFieldWithSameShapeQuery(Optional.Present(Episode.NEWHOPE)) + ) { + assertFalse(hasErrors()) + assertTrue(data?.hero?.onDroid != null) + assertEquals(data?.hero?.onDroid?.property, "Astromech") + } + + @Test + fun polymorphicHumanFieldsGetParsedToHuman() = basicTest( + "MergedFieldWithSameShape_Human.json", + MergedFieldWithSameShapeQuery(Optional.Present(Episode.NEWHOPE)) + ) { + assertFalse(hasErrors()) + assertTrue(data?.hero?.onHuman != null) + assertEquals(data?.hero?.onHuman?.property, "Tatooine") + } +} diff --git a/tests/models-operation-based-with-interfaces/src/commonTest/kotlin/test/StoreTest.kt b/tests/models-operation-based-with-interfaces/src/commonTest/kotlin/test/StoreTest.kt new file mode 100644 index 00000000..829e8138 --- /dev/null +++ b/tests/models-operation-based-with-interfaces/src/commonTest/kotlin/test/StoreTest.kt @@ -0,0 +1,146 @@ +package test + +import codegen.models.HeroAndFriendsWithFragmentsQuery +import codegen.models.HeroAndFriendsWithTypenameQuery +import codegen.models.fragment.HeroWithFriendsFragment +import codegen.models.fragment.HeroWithFriendsFragmentImpl +import codegen.models.fragment.HumanWithIdFragment +import codegen.models.fragment.HumanWithIdFragmentImpl +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.api.CacheKey +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import testFixtureToUtf8 +import kotlin.test.Test +import kotlin.test.assertEquals + +class StoreTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = IdCacheKeyGenerator() + ) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + @Test + fun readFragmentFromStore() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsWithTypename.json")) + apolloClient.query(HeroAndFriendsWithTypenameQuery()).execute() + + val heroWithFriendsFragment = store.readFragment( + HeroWithFriendsFragmentImpl(), + CacheKey("Character:2001"), + ).data + assertEquals(heroWithFriendsFragment.id, "2001") + assertEquals(heroWithFriendsFragment.name, "R2-D2") + assertEquals(heroWithFriendsFragment.friends?.size, 3) + assertEquals(heroWithFriendsFragment.friends?.get(0)?.humanWithIdFragment?.id, "1000") + assertEquals(heroWithFriendsFragment.friends?.get(0)?.humanWithIdFragment?.name, "Luke Skywalker") + assertEquals(heroWithFriendsFragment.friends?.get(1)?.humanWithIdFragment?.id, "1002") + assertEquals(heroWithFriendsFragment.friends?.get(1)?.humanWithIdFragment?.name, "Han Solo") + assertEquals(heroWithFriendsFragment.friends?.get(2)?.humanWithIdFragment?.id, "1003") + assertEquals(heroWithFriendsFragment.friends?.get(2)?.humanWithIdFragment?.name, "Leia Organa") + + var fragment = store.readFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1000"), + ).data + + assertEquals(fragment.id, "1000") + assertEquals(fragment.name, "Luke Skywalker") + + fragment = store.readFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1002"), + ).data + assertEquals(fragment.id, "1002") + assertEquals(fragment.name, "Han Solo") + + fragment = store.readFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1003"), + ).data + assertEquals(fragment.id, "1003") + assertEquals(fragment.name, "Leia Organa") + } + + /** + * Modify the store by writing fragments + */ + @Test + fun fragments() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNamesWithIDs.json")) + val query = HeroAndFriendsWithFragmentsQuery() + var response = apolloClient.query(query).execute() + assertEquals(response.data?.hero?.__typename, "Droid") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.id, "2001") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.name, "R2-D2") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.size, 3) + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(0)?.humanWithIdFragment?.id, "1000") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(0)?.humanWithIdFragment?.name, "Luke Skywalker") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(1)?.humanWithIdFragment?.id, "1002") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(1)?.humanWithIdFragment?.name, "Han Solo") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(2)?.humanWithIdFragment?.id, "1003") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(2)?.humanWithIdFragment?.name, "Leia Organa") + + store.writeFragment( + HeroWithFriendsFragmentImpl(), + CacheKey("Character:2001"), + HeroWithFriendsFragment( + "2001", + "R222-D222", + listOf( + HeroWithFriendsFragment.HumanFriend( + "Human", + HumanWithIdFragment( + "1000", + "SuperMan" + ) + ), + HeroWithFriendsFragment.HumanFriend( + "Human", + HumanWithIdFragment( + "1002", + "Han Solo" + ) + ), + ) + ), + ) + + store.writeFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1002"), + HumanWithIdFragment( + "1002", + "Beast" + ), + ) + + // Values should have changed + response = apolloClient.query(query).execute() + assertEquals(response.data?.hero?.__typename, "Droid") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.id, "2001") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.name, "R222-D222") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.size, 2) + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(0)?.humanWithIdFragment?.id, "1000") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(0)?.humanWithIdFragment?.name, "SuperMan") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(1)?.humanWithIdFragment?.id, "1002") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(1)?.humanWithIdFragment?.name, "Beast") + } +} diff --git a/tests/models-operation-based/build.gradle.kts b/tests/models-operation-based/build.gradle.kts new file mode 100644 index 00000000..06dd0f58 --- /dev/null +++ b/tests/models-operation-based/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.apollo) +} + +kotlin { + configureKmp( + withJs = true, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) + + sourceSets { + getByName("commonMain") { + dependencies { + implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + getByName("commonTest") { + dependencies { + implementation(libs.apollo.testing.support) + implementation(libs.apollo.mockserver) + implementation(libs.kotlin.test) + } + } + } +} + +apollo { + service("fixtures") { + srcDir(file("../models-fixtures/graphql")) + packageName.set("codegen.models") + generateFragmentImplementations.set(true) + } +} diff --git a/tests/models-operation-based/src/commonTest/kotlin/Utils.kt b/tests/models-operation-based/src/commonTest/kotlin/Utils.kt new file mode 100644 index 00000000..37d2c960 --- /dev/null +++ b/tests/models-operation-based/src/commonTest/kotlin/Utils.kt @@ -0,0 +1,4 @@ +import com.apollographql.apollo.testing.pathToUtf8 + +@Suppress("DEPRECATION") +fun testFixtureToUtf8(name: String) = pathToUtf8("models-fixtures/json/$name") diff --git a/tests/models-operation-based/src/commonTest/kotlin/test/BasicTest.kt b/tests/models-operation-based/src/commonTest/kotlin/test/BasicTest.kt new file mode 100644 index 00000000..2ad98f9b --- /dev/null +++ b/tests/models-operation-based/src/commonTest/kotlin/test/BasicTest.kt @@ -0,0 +1,89 @@ +package test + +import codegen.models.HeroParentTypeDependentFieldQuery +import codegen.models.MergedFieldWithSameShapeQuery +import codegen.models.type.Episode +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Optional +import com.apollographql.apollo.api.Query +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import testFixtureToUtf8 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BasicTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = IdCacheKeyGenerator() + ) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + private fun basicTest(resourceName: String, query: Query, block: ApolloResponse.() -> Unit) = + runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8(resourceName)) + var response = apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + response.block() + response = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute() + response.block() + } + + @Test + @Throws(Exception::class) + fun heroParentTypeDependentField() = basicTest( + "HeroParentTypeDependentField.json", + HeroParentTypeDependentFieldQuery(Optional.Present(Episode.NEWHOPE)) + ) { + + assertFalse(hasErrors()) + assertEquals(data?.hero?.name, "R2-D2") + assertEquals(data?.hero?.name, "R2-D2") + val hero = data?.hero?.onDroid!! + assertEquals(hero.friends?.size, 3) + assertEquals(hero.friends?.get(0)?.name, "Luke Skywalker") + assertEquals(hero.friends?.get(0)?.name, "Luke Skywalker") + assertEquals((hero.friends?.get(0)?.onHuman)?.height, 1.72) + } + + + @Test + fun polymorphicDroidFieldsGetParsedToDroid() = basicTest( + "MergedFieldWithSameShape_Droid.json", + MergedFieldWithSameShapeQuery(Optional.Present(Episode.NEWHOPE)) + ) { + assertFalse(hasErrors()) + assertTrue(data?.hero?.onDroid != null) + assertEquals(data?.hero?.onDroid?.property, "Astromech") + } + + @Test + fun polymorphicHumanFieldsGetParsedToHuman() = basicTest( + "MergedFieldWithSameShape_Human.json", + MergedFieldWithSameShapeQuery(Optional.Present(Episode.NEWHOPE)) + ) { + assertFalse(hasErrors()) + assertTrue(data?.hero?.onHuman != null) + assertEquals(data?.hero?.onHuman?.property, "Tatooine") + } +} diff --git a/tests/models-operation-based/src/commonTest/kotlin/test/StoreTest.kt b/tests/models-operation-based/src/commonTest/kotlin/test/StoreTest.kt new file mode 100644 index 00000000..5c500a81 --- /dev/null +++ b/tests/models-operation-based/src/commonTest/kotlin/test/StoreTest.kt @@ -0,0 +1,146 @@ +package test + +import codegen.models.HeroAndFriendsWithFragmentsQuery +import codegen.models.HeroAndFriendsWithTypenameQuery +import codegen.models.fragment.HeroWithFriendsFragment +import codegen.models.fragment.HeroWithFriendsFragmentImpl +import codegen.models.fragment.HumanWithIdFragment +import codegen.models.fragment.HumanWithIdFragmentImpl +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.api.CacheKey +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import testFixtureToUtf8 +import kotlin.test.Test +import kotlin.test.assertEquals + +class StoreTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = IdCacheKeyGenerator() + ) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + @Test + fun readFragmentFromStore() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsWithTypename.json")) + apolloClient.query(HeroAndFriendsWithTypenameQuery()).execute() + + val heroWithFriendsFragment = store.readFragment( + HeroWithFriendsFragmentImpl(), + CacheKey("Character:2001"), + ).data + assertEquals(heroWithFriendsFragment.id, "2001") + assertEquals(heroWithFriendsFragment.name, "R2-D2") + assertEquals(heroWithFriendsFragment.friends?.size, 3) + assertEquals(heroWithFriendsFragment.friends?.get(0)?.humanWithIdFragment?.id, "1000") + assertEquals(heroWithFriendsFragment.friends?.get(0)?.humanWithIdFragment?.name, "Luke Skywalker") + assertEquals(heroWithFriendsFragment.friends?.get(1)?.humanWithIdFragment?.id, "1002") + assertEquals(heroWithFriendsFragment.friends?.get(1)?.humanWithIdFragment?.name, "Han Solo") + assertEquals(heroWithFriendsFragment.friends?.get(2)?.humanWithIdFragment?.id, "1003") + assertEquals(heroWithFriendsFragment.friends?.get(2)?.humanWithIdFragment?.name, "Leia Organa") + + var fragment = store.readFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1000"), + ).data + + assertEquals(fragment.id, "1000") + assertEquals(fragment.name, "Luke Skywalker") + + fragment = store.readFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1002"), + ).data + assertEquals(fragment.id, "1002") + assertEquals(fragment.name, "Han Solo") + + fragment = store.readFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1003"), + ).data + assertEquals(fragment.id, "1003") + assertEquals(fragment.name, "Leia Organa") + } + + /** + * Modify the store by writing fragments + */ + @Test + fun fragments() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNamesWithIDs.json")) + val query = HeroAndFriendsWithFragmentsQuery() + var response = apolloClient.query(query).execute() + assertEquals(response.data?.hero?.__typename, "Droid") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.id, "2001") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.name, "R2-D2") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.size, 3) + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(0)?.humanWithIdFragment?.id, "1000") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(0)?.humanWithIdFragment?.name, "Luke Skywalker") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(1)?.humanWithIdFragment?.id, "1002") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(1)?.humanWithIdFragment?.name, "Han Solo") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(2)?.humanWithIdFragment?.id, "1003") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(2)?.humanWithIdFragment?.name, "Leia Organa") + + store.writeFragment( + HeroWithFriendsFragmentImpl(), + CacheKey("Character:2001"), + HeroWithFriendsFragment( + "2001", + "R222-D222", + listOf( + HeroWithFriendsFragment.Friend( + "Human", + HumanWithIdFragment( + "1000", + "SuperMan" + ) + ), + HeroWithFriendsFragment.Friend( + "Human", + HumanWithIdFragment( + "1002", + "Han Solo" + ) + ), + ) + ), + ) + + store.writeFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1002"), + HumanWithIdFragment( + "1002", + "Beast" + ), + ) + + // Values should have changed + response = apolloClient.query(query).execute() + assertEquals(response.data?.hero?.__typename, "Droid") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.id, "2001") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.name, "R222-D222") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.size, 2) + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(0)?.humanWithIdFragment?.id, "1000") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(0)?.humanWithIdFragment?.name, "SuperMan") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(1)?.humanWithIdFragment?.id, "1002") + assertEquals(response.data?.hero?.heroWithFriendsFragment?.friends?.get(1)?.humanWithIdFragment?.name, "Beast") + } +} diff --git a/tests/models-response-based/build.gradle.kts b/tests/models-response-based/build.gradle.kts new file mode 100644 index 00000000..0eff709d --- /dev/null +++ b/tests/models-response-based/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.apollo) +} + +kotlin { + configureKmp( + withJs = true, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) + + sourceSets { + getByName("commonMain") { + dependencies { + implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + getByName("commonTest") { + dependencies { + implementation(libs.apollo.testing.support) + implementation(libs.apollo.mockserver) + implementation(libs.kotlin.test) + } + } + } +} + +apollo { + service("fixtures") { + srcDir(file("../models-fixtures/graphql")) + packageName.set("codegen.models") + generateFragmentImplementations.set(true) + mapScalar("Date", "kotlin.Long") + codegenModels.set("responseBased") + sealedClassesForEnumsMatching.set(setOf("StarshipType")) + } +} diff --git a/tests/models-response-based/src/commonTest/kotlin/Utils.kt b/tests/models-response-based/src/commonTest/kotlin/Utils.kt new file mode 100644 index 00000000..37d2c960 --- /dev/null +++ b/tests/models-response-based/src/commonTest/kotlin/Utils.kt @@ -0,0 +1,4 @@ +import com.apollographql.apollo.testing.pathToUtf8 + +@Suppress("DEPRECATION") +fun testFixtureToUtf8(name: String) = pathToUtf8("models-fixtures/json/$name") diff --git a/tests/models-response-based/src/commonTest/kotlin/test/BasicTest.kt b/tests/models-response-based/src/commonTest/kotlin/test/BasicTest.kt new file mode 100644 index 00000000..797b1961 --- /dev/null +++ b/tests/models-response-based/src/commonTest/kotlin/test/BasicTest.kt @@ -0,0 +1,109 @@ +package test + +import codegen.models.HeroHumanOrDroidQuery +import codegen.models.HeroParentTypeDependentFieldQuery +import codegen.models.HeroParentTypeDependentFieldQuery.Data.DroidHero.Friend.Companion.asHuman +import codegen.models.HeroParentTypeDependentFieldQuery.Data.Hero.Companion.asDroid +import codegen.models.MergedFieldWithSameShapeQuery +import codegen.models.MergedFieldWithSameShapeQuery.Data.Hero.Companion.asDroid +import codegen.models.MergedFieldWithSameShapeQuery.Data.Hero.Companion.asHuman +import codegen.models.type.Episode +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Optional +import com.apollographql.apollo.api.Query +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import testFixtureToUtf8 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BasicTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = IdCacheKeyGenerator() + ) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + private fun basicTest(resourceName: String, query: Query, block: ApolloResponse.() -> Unit) = + runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8(resourceName)) + var response = apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + response.block() + response = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute() + response.block() + } + + @Test + @Throws(Exception::class) + fun heroParentTypeDependentField() = basicTest( + "HeroParentTypeDependentField.json", + HeroParentTypeDependentFieldQuery(Optional.Present(Episode.NEWHOPE)) + ) { + + assertFalse(hasErrors()) + assertEquals(data?.hero?.name, "R2-D2") + assertEquals(data?.hero?.name, "R2-D2") + val hero = data?.hero?.asDroid()!! + assertEquals(hero.friends?.size, 3) + assertEquals(hero.friends?.get(0)?.name, "Luke Skywalker") + assertEquals(hero.friends?.get(0)?.name, "Luke Skywalker") + assertEquals((hero.friends?.get(0)?.asHuman())?.height, 1.72) + } + + + @Test + fun polymorphicDroidFieldsGetParsedToDroid() = basicTest( + "MergedFieldWithSameShape_Droid.json", + MergedFieldWithSameShapeQuery(Optional.Present(Episode.NEWHOPE)) + ) { + + assertFalse(hasErrors()) + assertTrue(data?.hero is MergedFieldWithSameShapeQuery.Data.DroidHero) + assertEquals(data?.hero?.asDroid()?.property, "Astromech") + } + + @Test + fun polymorphicHumanFieldsGetParsedToHuman() = basicTest( + "MergedFieldWithSameShape_Human.json", + MergedFieldWithSameShapeQuery(Optional.Present(Episode.NEWHOPE)) + ) { + + assertFalse(hasErrors()) + assertTrue(data?.hero is MergedFieldWithSameShapeQuery.Data.HumanHero) + assertEquals(data?.hero?.asHuman()?.property, "Tatooine") + } + + @Test + fun canUseExhaustiveWhen() = basicTest( + "HeroHumanOrDroid.json", + HeroHumanOrDroidQuery(Optional.Present(Episode.NEWHOPE)) + ) { + val name = when (val hero = data!!.hero!!) { + is HeroHumanOrDroidQuery.Data.DroidHero -> hero.name + is HeroHumanOrDroidQuery.Data.HumanHero -> hero.name + is HeroHumanOrDroidQuery.Data.OtherHero -> hero.name + } + assertEquals(name, "R2-D2") + } +} diff --git a/tests/models-response-based/src/commonTest/kotlin/test/StoreTest.kt b/tests/models-response-based/src/commonTest/kotlin/test/StoreTest.kt new file mode 100644 index 00000000..c02b0d84 --- /dev/null +++ b/tests/models-response-based/src/commonTest/kotlin/test/StoreTest.kt @@ -0,0 +1,143 @@ +package test + +import codegen.models.HeroAndFriendsWithFragmentsQuery +import codegen.models.HeroAndFriendsWithFragmentsQuery.Data.Hero.Companion.heroWithFriendsFragment +import codegen.models.HeroAndFriendsWithTypenameQuery +import codegen.models.fragment.HeroWithFriendsFragment.Friend.Companion.asHuman +import codegen.models.fragment.HeroWithFriendsFragment.Friend.Companion.humanWithIdFragment +import codegen.models.fragment.HeroWithFriendsFragmentImpl +import codegen.models.fragment.HumanWithIdFragmentImpl +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.api.CacheKey +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import testFixtureToUtf8 +import kotlin.test.Test +import kotlin.test.assertEquals + +class StoreTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = IdCacheKeyGenerator() + ) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + @Test + fun readFragmentFromStore() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsWithTypename.json")) + apolloClient.query(HeroAndFriendsWithTypenameQuery()).execute() + + val heroWithFriendsFragment = store.readFragment( + HeroWithFriendsFragmentImpl(), + CacheKey("Character:2001"), + ).data + assertEquals(heroWithFriendsFragment.id, "2001") + assertEquals(heroWithFriendsFragment.name, "R2-D2") + assertEquals(heroWithFriendsFragment.friends?.size, 3) + assertEquals(heroWithFriendsFragment.friends?.get(0)?.asHuman()?.id, "1000") + assertEquals(heroWithFriendsFragment.friends?.get(0)?.asHuman()?.name, "Luke Skywalker") + assertEquals(heroWithFriendsFragment.friends?.get(1)?.asHuman()?.id, "1002") + assertEquals(heroWithFriendsFragment.friends?.get(1)?.asHuman()?.name, "Han Solo") + assertEquals(heroWithFriendsFragment.friends?.get(2)?.asHuman()?.id, "1003") + assertEquals(heroWithFriendsFragment.friends?.get(2)?.asHuman()?.name, "Leia Organa") + + var fragment = store.readFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1000"), + ).data + + assertEquals(fragment.id, "1000") + assertEquals(fragment.name, "Luke Skywalker") + + fragment = store.readFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1002"), + ).data + assertEquals(fragment.id, "1002") + assertEquals(fragment.name, "Han Solo") + + fragment = store.readFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1003"), + ).data + assertEquals(fragment.id, "1003") + assertEquals(fragment.name, "Leia Organa") + } + + /** + * Modify the store by writing fragments + */ + @Test + fun fragments() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNamesWithIDs.json")) + val query = HeroAndFriendsWithFragmentsQuery() + var response = apolloClient.query(query).execute() + assertEquals(response.data?.hero?.__typename, "Droid") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.id, "2001") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.name, "R2-D2") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.size, 3) + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.get(0)?.humanWithIdFragment()?.id, "1000") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.get(0)?.humanWithIdFragment()?.name, "Luke Skywalker") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.get(1)?.humanWithIdFragment()?.id, "1002") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.get(1)?.humanWithIdFragment()?.name, "Han Solo") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.get(2)?.humanWithIdFragment()?.id, "1003") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.get(2)?.humanWithIdFragment()?.name, "Leia Organa") + + store.writeFragment( + HeroWithFriendsFragmentImpl(), + CacheKey("Character:2001"), + HeroWithFriendsFragmentImpl.Data( + id = "2001", + name = "R222-D222", + friends = listOf( + HeroWithFriendsFragmentImpl.Data.HumanFriend( + __typename = "Human", + id = "1000", + name = "SuperMan" + ), + HeroWithFriendsFragmentImpl.Data.HumanFriend( + __typename = "Human", + id = "1002", + name = "Han Solo" + ), + ) + ), + ) + + store.writeFragment( + HumanWithIdFragmentImpl(), + CacheKey("Character:1002"), + HumanWithIdFragmentImpl.Data( + id = "1002", + name = "Beast" + ), + ) + + // Values should have changed + response = apolloClient.query(query).execute() + assertEquals(response.data?.hero?.__typename, "Droid") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.id, "2001") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.name, "R222-D222") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.size, 2) + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.get(0)?.humanWithIdFragment()?.id, "1000") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.get(0)?.humanWithIdFragment()?.name, "SuperMan") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.get(1)?.humanWithIdFragment()?.id, "1002") + assertEquals(response.data?.hero?.heroWithFriendsFragment()?.friends?.get(1)?.humanWithIdFragment()?.name, "Beast") + } +} diff --git a/tests/normalization-tests/build.gradle.kts b/tests/normalization-tests/build.gradle.kts new file mode 100644 index 00000000..93204aec --- /dev/null +++ b/tests/normalization-tests/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.apollo) +} + +kotlin { + configureKmp( + withJs = true, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) + + sourceSets { + getByName("commonMain") { + dependencies { + implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + getByName("commonTest") { + dependencies { + implementation(libs.apollo.testing.support) + implementation(libs.apollo.mockserver) + implementation(libs.kotlin.test) + } + } + + getByName("jvmTest") { + dependencies { + implementation(libs.slf4j.nop) + } + } + } +} + +apollo { + service("1") { + srcDir("src/commonMain/graphql/1") + packageName.set("com.example.one") + } + service("2") { + srcDir("src/commonMain/graphql/2") + packageName.set("com.example.two") + } + service("3") { + srcDir("src/commonMain/graphql/3") + packageName.set("com.example.three") + } +} diff --git a/tests/normalization-tests/src/commonMain/graphql/1/operations.graphql b/tests/normalization-tests/src/commonMain/graphql/1/operations.graphql new file mode 100644 index 00000000..80b94d73 --- /dev/null +++ b/tests/normalization-tests/src/commonMain/graphql/1/operations.graphql @@ -0,0 +1,43 @@ +query Issue3672 { + viewer { + libraries(limit: 1) { + book { id } + ...nestedBook + ...anotherBookFragment + } + } +} + +fragment nestedBook on Library { + name + book { + name + } +} + +fragment anotherBookFragment on Library { + book { + year + } +} + +fragment bookAuthor on Book { + author +} + + +query Issue2818 { + home { + ...sectionFragment + sectionA { + name + } + } +} + +fragment sectionFragment on Home { + sectionA { + id + imageUrl + } +} diff --git a/tests/normalization-tests/src/commonMain/graphql/1/schema.graphqls b/tests/normalization-tests/src/commonMain/graphql/1/schema.graphqls new file mode 100644 index 00000000..0f167c08 --- /dev/null +++ b/tests/normalization-tests/src/commonMain/graphql/1/schema.graphqls @@ -0,0 +1,31 @@ +type Query { + viewer: Viewer! + home: Home! +} + +type Viewer { + libraries(limit: Int): [Library!]! +} + +type Book { + id: String! + name: String! + year: Int! + author: String! +} + +type Library { + id: String! + name: String! + book: Book! +} + +type Home { + sectionA: SectionA +} + +type SectionA { + id: String! + name: String! + imageUrl: String +} diff --git a/tests/normalization-tests/src/commonMain/graphql/2/operations.graphql b/tests/normalization-tests/src/commonMain/graphql/2/operations.graphql new file mode 100644 index 00000000..cd5e8376 --- /dev/null +++ b/tests/normalization-tests/src/commonMain/graphql/2/operations.graphql @@ -0,0 +1,23 @@ +query NestedFragment { + viewer { + libraries(limit: 1) { + id + books { id } + ...nestedBook + } + } +} + +fragment nestedBook on Library { + books { + author { id } + } +} + +query getCountry($code:ID!) { + #efredsver + country(code:$code) { + code @skip(if:true) + name + } +} \ No newline at end of file diff --git a/tests/normalization-tests/src/commonMain/graphql/2/schema.graphqls b/tests/normalization-tests/src/commonMain/graphql/2/schema.graphqls new file mode 100644 index 00000000..5542cc08 --- /dev/null +++ b/tests/normalization-tests/src/commonMain/graphql/2/schema.graphqls @@ -0,0 +1,36 @@ +type Query { + viewer: Viewer! + home: Home! + country(code: ID!): Country +} + +type Viewer { + libraries(limit: Int): [Library!]! +} + +type Book { + id: String! + name: String! + year: Int! + author: Author! +} + +type Library { + id: String! + name: String! + books: [Book]! +} + +type Author { + id: String! + name: String! +} + +type Home { + address: String +} + +type Country { + code: ID! + name: String! +} \ No newline at end of file diff --git a/tests/normalization-tests/src/commonMain/graphql/3/extra.graphqls b/tests/normalization-tests/src/commonMain/graphql/3/extra.graphqls new file mode 100644 index 00000000..dd165da5 --- /dev/null +++ b/tests/normalization-tests/src/commonMain/graphql/3/extra.graphqls @@ -0,0 +1,9 @@ +extend type Query @fieldPolicy(forField: "country" keyArgs: "code") + +extend type Book @typePolicy(keyFields: "id") + +extend type Library @typePolicy(keyFields: "id") + +extend type Author @typePolicy(keyFields: "id") + +extend type Country @typePolicy(keyFields: "code") diff --git a/tests/normalization-tests/src/commonMain/graphql/3/operations.graphql b/tests/normalization-tests/src/commonMain/graphql/3/operations.graphql new file mode 100644 index 00000000..6c156243 --- /dev/null +++ b/tests/normalization-tests/src/commonMain/graphql/3/operations.graphql @@ -0,0 +1,64 @@ +query GetBooksByIds($bookIds: [ID!]!) { + viewer { + libraries(limit: 1) { + books(bookIds: $bookIds) { + name + year + } + } + } +} + +query GetBooksByIdsPaginated($bookIds: [ID!]!, $after: String) { + viewer { + libraries(limit: 1) { + booksPaginated(bookIds: $bookIds, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + name + year + } + } + } + } + } +} + +query GetBooksByIdsPaginatedNoCursors($bookIds: [ID!]!, $after: String) { + viewer { + libraries(limit: 1) { + booksPaginated(bookIds: $bookIds, after: $after) { + edges { + node { + name + year + } + } + } + } + } +} + +query GetBooksByIdsPaginatedNoCursorsWithFragment($bookIds: [ID!]!, $after: String) { + viewer { + libraries(limit: 1) { + booksPaginated(bookIds: $bookIds, after: $after) { + edges { + ...BookEdge + } + } + } + } +} + +fragment BookEdge on BookEdge { + node { + name + year + } +} diff --git a/tests/normalization-tests/src/commonMain/graphql/3/schema.graphqls b/tests/normalization-tests/src/commonMain/graphql/3/schema.graphqls new file mode 100644 index 00000000..1db461d2 --- /dev/null +++ b/tests/normalization-tests/src/commonMain/graphql/3/schema.graphqls @@ -0,0 +1,52 @@ +type Query { + viewer: Viewer! + home: Home! + country(code: ID!): Country +} + +type Viewer { + libraries(limit: Int): [Library!]! +} + +type Book { + id: String! + name: String! + year: Int! + author: Author! +} + +type Library { + id: String! + name: String! + books(bookIds: [ID!]!): [Book]! + booksPaginated(bookIds: [ID!]!, first: Int = 10, after: String): BookConnection! +} + +type BookConnection { + edges: [BookEdge!]! + pageInfo: PageInfo! +} + +type BookEdge { + node: Book! + cursor: String! +} + +type PageInfo { + hasNextPage: Boolean! + endCursor: String! +} + +type Author { + id: String! + name: String! +} + +type Home { + address: String +} + +type Country { + code: ID! + name: String! +} \ No newline at end of file diff --git a/tests/normalization-tests/src/commonTest/kotlin/com/example/NormalizationTest.kt b/tests/normalization-tests/src/commonTest/kotlin/com/example/NormalizationTest.kt new file mode 100644 index 00000000..e9a4b9ce --- /dev/null +++ b/tests/normalization-tests/src/commonTest/kotlin/com/example/NormalizationTest.kt @@ -0,0 +1,312 @@ +package com.example + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.json.jsonReader +import com.apollographql.apollo.api.toApolloResponse +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.CacheKey +import com.apollographql.cache.normalized.api.CacheKeyGenerator +import com.apollographql.cache.normalized.api.CacheKeyGeneratorContext +import com.apollographql.cache.normalized.api.CacheKeyResolver +import com.apollographql.cache.normalized.api.CacheResolver +import com.apollographql.cache.normalized.api.FieldPolicyCacheResolver +import com.apollographql.cache.normalized.api.ResolverContext +import com.apollographql.cache.normalized.api.TypePolicyCacheKeyGenerator +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import com.example.one.Issue2818Query +import com.example.one.Issue3672Query +import com.example.one.fragment.SectionFragment +import com.example.three.GetBooksByIdsPaginatedNoCursorsQuery +import com.example.three.GetBooksByIdsPaginatedNoCursorsWithFragmentQuery +import com.example.three.GetBooksByIdsPaginatedQuery +import com.example.three.GetBooksByIdsQuery +import com.example.three.type.Book +import com.example.three.type.BookConnection +import com.example.three.type.BookEdge +import com.example.two.GetCountryQuery +import com.example.two.NestedFragmentQuery +import okio.Buffer +import kotlin.test.Test +import kotlin.test.assertEquals + +internal object IdBasedCacheKeyResolver : CacheResolver, CacheKeyGenerator { + + override fun cacheKeyForObject(obj: Map, context: CacheKeyGeneratorContext) = + obj["id"]?.toString()?.let(::CacheKey) ?: TypePolicyCacheKeyGenerator.cacheKeyForObject(obj, context) + + override fun resolveField(context: ResolverContext): Any? { + return FieldPolicyCacheResolver.resolveField(context) + } +} + +class NormalizationTest { + @Test + fun issue3672() = runTest { + val store = ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = IdBasedCacheKeyResolver, + cacheResolver = IdBasedCacheKeyResolver + ) + + val query = Issue3672Query() + + val data1 = + Buffer().writeUtf8(nestedResponse).jsonReader().toApolloResponse(operation = query, customScalarAdapters = CustomScalarAdapters.Empty) + .dataOrThrow() + store.writeOperation(query, data1) + + val data2 = store.readOperation(query).data + assertEquals(data2, data1) + } + + @Test + fun issue3672_2() = runTest { + val store = ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = IdBasedCacheKeyResolver, + cacheResolver = IdBasedCacheKeyResolver + ) + + val query = NestedFragmentQuery() + + val data1 = Buffer().writeUtf8(nestedResponse_list).jsonReader() + .toApolloResponse(operation = query, customScalarAdapters = CustomScalarAdapters.Empty).dataOrThrow() + store.writeOperation(query, data1) + + val data2 = store.readOperation(query).data + assertEquals(data2, data1) + } + + @Test + fun issue2818() = runTest { + val apolloStore = ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = IdBasedCacheKeyResolver, + cacheResolver = IdBasedCacheKeyResolver + ) + + apolloStore.writeOperation( + Issue2818Query(), + Issue2818Query.Data( + Issue2818Query.Home( + __typename = "Home", + sectionA = Issue2818Query.SectionA( + name = "section-name", + ), + sectionFragment = SectionFragment( + sectionA = SectionFragment.SectionA( + id = "section-id", + imageUrl = "https://...", + ), + ), + ), + ), + ) + + val data = apolloStore.readOperation(Issue2818Query()).data + assertEquals("section-name", data.home.sectionA?.name) + assertEquals("section-id", data.home.sectionFragment.sectionA?.id) + assertEquals("https://...", data.home.sectionFragment.sectionA?.imageUrl) + } + + @Test + // See https://github.com/apollographql/apollo-kotlin/issues/4772 + fun issue4772() = runTest { + val mockserver = MockServer() + val apolloClient = ApolloClient.Builder() + .serverUrl(mockserver.url()) + .normalizedCache(MemoryCacheFactory()) + .build() + + mockserver.enqueueString(""" + { + "data": { + "country": { + "name": "Foo" + } + } + } + """.trimIndent() + ) + apolloClient.query(GetCountryQuery("foo")).execute().run { + check(data?.country?.name == "Foo") + } + apolloClient.close() + mockserver.close() + } + + @Test + fun resolveList() = runTest { + val mockserver = MockServer() + val apolloClient = ApolloClient.Builder() + .serverUrl(mockserver.url()) + .store( + ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = TypePolicyCacheKeyGenerator, + cacheResolver = object : CacheKeyResolver() { + override fun cacheKeyForField(context: ResolverContext): CacheKey? { + // Same behavior as FieldPolicyCacheResolver + val keyArgsValues = context.field.argumentValues(context.variables) { it.definition.isKey }.values.map { it.toString() } + if (keyArgsValues.isNotEmpty()) { + return CacheKey(context.field.type.rawType().name, keyArgsValues) + } + return null + } + + @Suppress("UNCHECKED_CAST") + override fun listOfCacheKeysForField(context: ResolverContext): List? { + return if (context.field.type.rawType() == Book.type) { + val bookIds = context.field.argumentValues(context.variables)["bookIds"] as List + bookIds.map { CacheKey(Book.type.name, it) } + } else { + null + } + } + } + ) + ) + .build() + + mockserver.enqueueString(""" + { + "data": { + "viewer": { + "libraries": [ + { + "__typename": "Library", + "id": "library-1", + "books": [ + { + "__typename": "Book", + "id": "book-1", + "name": "First book", + "year": 1991 + }, + { + "__typename": "Book", + "id": "book-2", + "name": "Second book", + "year": 1992 + } + ] + } + ] + } + } + } + """.trimIndent() + ) + + // Fetch from network + apolloClient.query(GetBooksByIdsQuery(listOf("book-1", "book-2"))).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // Fetch from the cache + val fromCache = apolloClient.query(GetBooksByIdsQuery(listOf("book-1"))).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals("First book", fromCache.data?.viewer?.libraries?.first()?.books?.first()?.name) + + apolloClient.close() + mockserver.close() + } + + @Test + fun resolvePaginatedList() = runTest { + val mockserver = MockServer() + val apolloClient = ApolloClient.Builder() + .serverUrl(mockserver.url()) + .store( + ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = TypePolicyCacheKeyGenerator, + cacheResolver = object : CacheResolver { + @Suppress("UNCHECKED_CAST") + override fun resolveField(context: ResolverContext): Any? { + if (context.field.type.rawType() == BookConnection.type) { + val bookIds = context.field.argumentValues(context.variables)["bookIds"] as List + return mapOf( + "edges" to bookIds.map { + mapOf( + "node" to CacheKey(Book.type.name, it), + "__typename" to BookEdge.type.name, + ) + }, + ) + } + + return FieldPolicyCacheResolver.resolveField(context) + } + } + ) + ) + .build() + + mockserver.enqueueString(""" + { + "data": { + "viewer": { + "libraries": [ + { + "__typename": "Library", + "id": "library-1", + "booksPaginated": { + "pageInfo": { + "__typename": "PageInfo", + "hasNextPage": false, + "endCursor": "book-2" + }, + "edges": [ + { + "__typename": "BookEdge", + "cursor": "cursor-book-1", + "node": { + "__typename": "Book", + "id": "book-1", + "name": "First book", + "year": 1991 + } + }, + { + "__typename": "BookEdge", + "cursor": "cursor-book-2", + "node": { + "__typename": "Book", + "id": "book-2", + "name": "Second book", + "year": 1992 + } + } + ] + } + } + ] + } + } + } + """.trimIndent() + ) + + // Fetch from network + apolloClient.query(GetBooksByIdsPaginatedQuery(listOf("book-1", "book-2"))).fetchPolicy(FetchPolicy.NetworkOnly).execute() + // println(NormalizedCache.prettifyDump(apolloClient.apolloStore.dump())) + + // Fetch from the cache + val fromCache1 = apolloClient.query(GetBooksByIdsPaginatedNoCursorsQuery(listOf("book-1"))).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals("First book", fromCache1.data?.viewer?.libraries?.first()?.booksPaginated?.edges?.first()?.node?.name) + + // Fetch from the cache (with fragment) + val fromCache2 = + apolloClient.query(GetBooksByIdsPaginatedNoCursorsWithFragmentQuery(listOf("book-1"))).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals("First book", fromCache2.data?.viewer?.libraries?.first()?.booksPaginated?.edges?.first()?.bookEdge?.node?.name) + + apolloClient.close() + mockserver.close() + } +} diff --git a/tests/normalization-tests/src/commonTest/kotlin/com/example/response.kt b/tests/normalization-tests/src/commonTest/kotlin/com/example/response.kt new file mode 100644 index 00000000..3093eed6 --- /dev/null +++ b/tests/normalization-tests/src/commonTest/kotlin/com/example/response.kt @@ -0,0 +1,52 @@ +package com.example + +// language=JSON +val nestedResponse = """ + { + "data": { + "viewer": { + "__typename": "Viewer", + "libraries": [ + { + "__typename": "Library", + "name": "library-1", + "book":{ + "__typename": "Book", + "id": "book=1", + "name": "name-1", + "year": 1991, + "author": "John Doe" + } + } + ] + } + } + } +""".trimIndent() + +// language=JSON +val nestedResponse_list = """ + { + "data": { + "viewer": { + "__typename": "Viewer", + "libraries": [ + { + "__typename": "Library", + "id": "library-1", + "books": [ + { + "__typename": "Book", + "id": "book-1", + "author": { + "__typename": "Author", + "id": "author-1" + } + } + ] + } + ] + } + } + } +""".trimIndent() diff --git a/tests/normalized-cache/build.gradle.kts b/tests/normalized-cache/build.gradle.kts index 9d521fb6..9f3bbebf 100644 --- a/tests/normalized-cache/build.gradle.kts +++ b/tests/normalized-cache/build.gradle.kts @@ -1,42 +1,27 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi - plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.apollo) } kotlin { - jvm() - macosX64() - macosArm64() - iosArm64() - iosX64() - iosSimulatorArm64() - watchosArm32() - watchosArm64() - watchosSimulatorArm64() - tvosArm64() - tvosX64() - tvosSimulatorArm64() - - @OptIn(ExperimentalKotlinGradlePluginApi::class) - applyDefaultHierarchyTemplate { - group("common") { - group("concurrent") { - group("native") { - group("apple") - } - group("jvmCommon") { - withJvm() - } - } - } - } + configureKmp( + withJs = true, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) sourceSets { getByName("commonMain") { dependencies { implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + getByName("concurrentMain") { + dependencies { + implementation("com.apollographql.cache:normalized-cache-sqlite-incubating") } } @@ -45,7 +30,7 @@ kotlin { implementation(libs.apollo.testing.support) implementation(libs.apollo.mockserver) implementation(libs.kotlin.test) - implementation("com.apollographql.cache:normalized-cache-sqlite-incubating") + implementation(libs.turbine) } } @@ -54,16 +39,47 @@ kotlin { implementation(libs.slf4j.nop) } } - - configureEach { - languageSettings.optIn("com.apollographql.apollo.annotations.ApolloExperimental") - languageSettings.optIn("com.apollographql.apollo.annotations.ApolloInternal") - } } } apollo { - service("service") { - packageName.set("test") + service("main") { + packageName.set("main") + srcDir(file("src/commonMain/graphql/main")) + } + + service("httpcache") { + packageName.set("httpcache") + srcDir(file("src/commonMain/graphql/httpcache")) + } + + service("normalizer") { + packageName.set("normalizer") + srcDir(file("src/commonMain/graphql/normalizer")) + generateFragmentImplementations.set(true) + mapScalarToKotlinString("Date") + mapScalarToKotlinString("Instant") + sealedClassesForEnumsMatching.set(listOf("Episode")) + generateOptionalOperationVariables.set(false) + } + + service("circular") { + packageName.set("circular") + srcDir(file("src/commonMain/graphql/circular")) + generateOptionalOperationVariables.set(false) + } + + service("declarativecache") { + packageName.set("declarativecache") + srcDir(file("src/commonMain/graphql/declarativecache")) + generateOptionalOperationVariables.set(false) } + + service("fragmentnormalizer") { + packageName.set("fragmentnormalizer") + srcDir(file("src/commonMain/graphql/fragmentnormalizer")) + generateOptionalOperationVariables.set(false) + generateFragmentImplementations.set(true) + } + } diff --git a/tests/normalized-cache/src/commonMain/graphql/circular/operations.graphql b/tests/normalized-cache/src/commonMain/graphql/circular/operations.graphql new file mode 100644 index 00000000..94e14636 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/circular/operations.graphql @@ -0,0 +1,8 @@ +query GetUser { + user { + id + friend { + id + } + } +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/circular/schema.graphqls b/tests/normalized-cache/src/commonMain/graphql/circular/schema.graphqls new file mode 100644 index 00000000..bd516d77 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/circular/schema.graphqls @@ -0,0 +1,9 @@ +type Query { + user: User! +} + +type User @typePolicy(keyFields: "id"){ + id: String! + name: String! + friend: User! +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/declarativecache/extra.graphqls b/tests/normalized-cache/src/commonMain/graphql/declarativecache/extra.graphqls new file mode 100644 index 00000000..231ec35c --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/declarativecache/extra.graphqls @@ -0,0 +1,12 @@ +extend type Author @typePolicy(keyFields: "firstName lastName") + +extend type Book @typePolicy(keyFields: "isbn") + +extend type Query @fieldPolicy(forField: "book", keyArgs: "isbn") @fieldPolicy(forField: "author", keyArgs: "firstName lastName") + +interface Node @typePolicy(keyFields: "id") { + id: ID! +} + +extend type Library implements Node + diff --git a/tests/normalized-cache/src/commonMain/graphql/declarativecache/operations.graphql b/tests/normalized-cache/src/commonMain/graphql/declarativecache/operations.graphql new file mode 100644 index 00000000..562acbf1 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/declarativecache/operations.graphql @@ -0,0 +1,54 @@ +query GetPromoBook { + promoBook { + # ISBN should be added automatically here + title + } +} + +query GetOtherBook { + otherBook { + isbn + title + } +} + +query GetPromoLibrary { + promoLibrary { + # id should be added automatically here + address + } +} + +query GetOtherLibrary { + otherLibrary { + id + address + } +} + +query GetBook($isbn: String!) { + book(isbn: $isbn) { + title + } +} + +query GetBooks($isbns: [String!]!) { + books(isbns: $isbns) { + title + } +} + +query GetPromoAuthor { + promoAuthor { + firstName + lastName + } +} + +query GetAuthor($firstName: String!, $lastName: String!) { + author(firstName: $firstName, lastName: $lastName) { + firstName + lastName + } +} + diff --git a/tests/normalized-cache/src/commonMain/graphql/declarativecache/schema.graphqls b/tests/normalized-cache/src/commonMain/graphql/declarativecache/schema.graphqls new file mode 100644 index 00000000..74d6ecfc --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/declarativecache/schema.graphqls @@ -0,0 +1,29 @@ +type Query { + promoAuthor: Author + + promoBook: Book + otherBook: Book + + promoLibrary: Library + otherLibrary: Library + + author(firstName: String!, lastName: String!): Author + book(isbn: String!): Book + books(isbns: [String!]!): [Book!]! +} + +type Library { + id: ID! + address: String! +} + +type Author { + firstName: String! + lastName: String! +} + +type Book { + isbn: String! + title: String! + author: Author +} diff --git a/tests/normalized-cache/src/commonMain/graphql/fragmentnormalizer/operations.graphql b/tests/normalized-cache/src/commonMain/graphql/fragmentnormalizer/operations.graphql new file mode 100644 index 00000000..ef3d2c73 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/fragmentnormalizer/operations.graphql @@ -0,0 +1,8 @@ +# From https://github.com/apollographql/apollo-kotlin/issues/3875 +fragment ConversationFragment on Conversation { + id + author { + fullName + } + read +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/fragmentnormalizer/schema.graphqls b/tests/normalized-cache/src/commonMain/graphql/fragmentnormalizer/schema.graphqls new file mode 100644 index 00000000..ae6e5ef3 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/fragmentnormalizer/schema.graphqls @@ -0,0 +1,14 @@ +type Query { + conversation: Conversation +} + +type Conversation { + id: ID! + author: Author! + read: Boolean! +} + +type Author { + id: ID! + fullName: String! +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/httpcache/AllFilms.graphql b/tests/normalized-cache/src/commonMain/graphql/httpcache/AllFilms.graphql new file mode 100644 index 00000000..956e5c8c --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/httpcache/AllFilms.graphql @@ -0,0 +1,9 @@ +query AllFilms { + allFilms(first: 100) { + totalCount + films { + title + releaseDate + } + } +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/httpcache/AllPlanets.graphql b/tests/normalized-cache/src/commonMain/graphql/httpcache/AllPlanets.graphql new file mode 100644 index 00000000..49bb86ed --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/httpcache/AllPlanets.graphql @@ -0,0 +1,25 @@ +query AllPlanets { + allPlanets(first: 300) { + planets { + ...PlanetFragment + filmConnection { + totalCount + films { + title + ...FilmFragment + } + } + } + } +} + +fragment FilmFragment on Film { + title + producers +} + +fragment PlanetFragment on Planet { + name + climates + surfaceWater +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/httpcache/DroidDetails.graphql b/tests/normalized-cache/src/commonMain/graphql/httpcache/DroidDetails.graphql new file mode 100644 index 00000000..abc3aeb3 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/httpcache/DroidDetails.graphql @@ -0,0 +1,14 @@ +query DroidDetails { + species(id: "c3BlY2llczoy") { + id + name + filmConnection { + edges { + node { + id + title + } + } + } + } +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/httpcache/schema.graphqls b/tests/normalized-cache/src/commonMain/graphql/httpcache/schema.graphqls new file mode 100644 index 00000000..83c3ddaf --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/httpcache/schema.graphqls @@ -0,0 +1,1411 @@ +type Root { + allFilms(after: String, first: Int, before: String, last: Int): FilmsConnection + film(id: ID, filmID: ID): Film + allPeople(after: String, first: Int, before: String, last: Int): PeopleConnection + person(id: ID, personID: ID): Person + allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection + planet(id: ID, planetID: ID): Planet + allSpecies(after: String, first: Int, before: String, last: Int): SpeciesConnection + species(id: ID, speciesID: ID): Species + allStarships(after: String, first: Int, before: String, last: Int): StarshipsConnection + starship(id: ID, starshipID: ID): Starship + allVehicles(after: String, first: Int, before: String, last: Int): VehiclesConnection + vehicle(id: ID, vehicleID: ID): Vehicle + """ + Fetches an object given its ID + """ + node("""The ID of an object""" id: ID!): Node +} + +""" +A connection to a list of items. +""" +type FilmsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [FilmsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +""" +Information about pagination in a connection. +""" +type PageInfo { + """ + When paginating forwards, are there more items? + """ + hasNextPage: Boolean! + """ + When paginating backwards, are there more items? + """ + hasPreviousPage: Boolean! + """ + When paginating backwards, the cursor to continue. + """ + startCursor: String + """ + When paginating forwards, the cursor to continue. + """ + endCursor: String +} + +""" +An edge in a connection. +""" +type FilmsEdge { + """ + The item at the end of the edge + """ + node: Film + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A single film. +""" +type Film implements Node { + """ + The title of this film. + """ + title: String + """ + The episode number of this film. + """ + episodeID: Int + """ + The opening paragraphs at the beginning of this film. + """ + openingCrawl: String + """ + The name of the director of this film. + """ + director: String + """ + The name(s) of the producer(s) of this film. + """ + producers: [String] + """ + The ISO 8601 date format of film release at original creator country. + """ + releaseDate: Date! + speciesConnection(after: String, first: Int, before: String, last: Int): FilmSpeciesConnection + starshipConnection(after: String, first: Int, before: String, last: Int): FilmStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): FilmVehiclesConnection + characterConnection(after: String, first: Int, before: String, last: Int): FilmCharactersConnection + planetConnection(after: String, first: Int, before: String, last: Int): FilmPlanetsConnection + """ + The ISO 8601 date format of the time that this resource was created. + """ + created: String + """ + The ISO 8601 date format of the time that this resource was edited. + """ + edited: String + """ + The ID of an object + """ + id: ID! +} + +""" +An object with an ID +""" +interface Node { + """ + The id of the object. + """ + id: ID! +} + +""" +A large mass, planet or planetoid in the Star Wars Universe, at the time of +0 ABY. +""" +type Planet implements Node { + """ + The name of this planet. + """ + name: String + """ + The diameter of this planet in kilometers. + """ + diameter: Int + """ + The number of standard hours it takes for this planet to complete a single + rotation on its axis. + """ + rotationPeriod: Int + """ + The number of standard days it takes for this planet to complete a single orbit + of its local star. + """ + orbitalPeriod: Int + """ + A number denoting the gravity of this planet, where "1" is normal or 1 standard + G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. + """ + gravity: String + """ + The average population of sentient beings inhabiting this planet. + """ + population: Int + """ + The climates of this planet. + """ + climates: [String] + """ + The terrains of this planet. + """ + terrains: [String] + """ + The percentage of the planet surface that is naturally occuring water or bodies + of water. + """ + surfaceWater: Float + residentConnection(after: String, first: Int, before: String, last: Int): PlanetResidentsConnection + filmConnection(after: String, first: Int, before: String, last: Int): PlanetFilmsConnection + """ + The ISO 8601 date format of the time that this resource was created. + """ + created: String + """ + The ISO 8601 date format of the time that this resource was edited. + """ + edited: String + """ + The ID of an object + """ + id: ID! +} + +""" +A connection to a list of items. +""" +type PlanetResidentsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [PlanetResidentsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + residents: [Person] +} + +""" +An edge in a connection. +""" +type PlanetResidentsEdge { + """ + The item at the end of the edge + """ + node: Person + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +An individual person or character within the Star Wars universe. +""" +type Person implements Node { + """ + The name of this person. + """ + name: String + """ + The birth year of the person, using the in-universe standard of BBY or ABY - + Before the Battle of Yavin or After the Battle of Yavin. The Battle of Yavin is + a battle that occurs at the end of Star Wars episode IV: A New Hope. + """ + birthYear: String + """ + The eye color of this person. Will be "unknown" if not known or "n/a" if the + person does not have an eye. + """ + eyeColor: String + """ + The gender of this person. Either "Male", "Female" or "unknown", + "n/a" if the person does not have a gender. + """ + gender: String + """ + The hair color of this person. Will be "unknown" if not known or "n/a" if the + person does not have hair. + """ + hairColor: String + """ + The height of the person in centimeters. + """ + height: Int + """ + The mass of the person in kilograms. + """ + mass: Int + """ + The skin color of this person. + """ + skinColor: String + """ + A planet that this person was born on or inhabits. + """ + homeworld: Planet + filmConnection(after: String, first: Int, before: String, last: Int): PersonFilmsConnection + """ + The species that this person belongs to, or null if unknown. + """ + species: Species + starshipConnection(after: String, first: Int, before: String, last: Int): PersonStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): PersonVehiclesConnection + """ + The ISO 8601 date format of the time that this resource was created. + """ + created: String + """ + The ISO 8601 date format of the time that this resource was edited. + """ + edited: String + """ + The ID of an object + """ + id: ID! +} + +""" +A connection to a list of items. +""" +type PersonFilmsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [PersonFilmsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +""" +An edge in a connection. +""" +type PersonFilmsEdge { + """ + The item at the end of the edge + """ + node: Film + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A type of person or character within the Star Wars Universe. +""" +type Species implements Node { + """ + The name of this species. + """ + name: String + """ + The classification of this species, such as "mammal" or "reptile". + """ + classification: String + """ + The designation of this species, such as "sentient". + """ + designation: String + """ + The average height of this species in centimeters. + """ + averageHeight: Float + """ + The average lifespan of this species in years. + """ + averageLifespan: Int + """ + Common eye colors for this species, null if this species does not typically + have eyes. + """ + eyeColors: [String] + """ + Common hair colors for this species, null if this species does not typically + have hair. + """ + hairColors: [String] + """ + Common skin colors for this species, null if this species does not typically + have skin. + """ + skinColors: [String] + """ + The language commonly spoken by this species. + """ + language: String + """ + A planet that this species originates from. + """ + homeworld: Planet + personConnection(after: String, first: Int, before: String, last: Int): SpeciesPeopleConnection + filmConnection(after: String, first: Int, before: String, last: Int): SpeciesFilmsConnection + """ + The ISO 8601 date format of the time that this resource was created. + """ + created: String + """ + The ISO 8601 date format of the time that this resource was edited. + """ + edited: String + """ + The ID of an object + """ + id: ID! +} + +""" +A connection to a list of items. +""" +type SpeciesPeopleConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [SpeciesPeopleEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] +} + +""" +An edge in a connection. +""" +type SpeciesPeopleEdge { + """ + The item at the end of the edge + """ + node: Person + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type SpeciesFilmsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [SpeciesFilmsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +""" +An edge in a connection. +""" +type SpeciesFilmsEdge { + """ + The item at the end of the edge + """ + node: Film + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type PersonStarshipsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [PersonStarshipsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +""" +An edge in a connection. +""" +type PersonStarshipsEdge { + """ + The item at the end of the edge + """ + node: Starship + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A single transport craft that has hyperdrive capability. +""" +type Starship implements Node { + """ + The name of this starship. The common name, such as "Death Star". + """ + name: String + """ + The model or official name of this starship. Such as "T-65 X-wing" or "DS-1 + Orbital Battle Station". + """ + model: String + """ + The class of this starship, such as "Starfighter" or "Deep Space Mobile + Battlestation" + """ + starshipClass: String + """ + The manufacturers of this starship. + """ + manufacturers: [String] + """ + The cost of this starship new, in galactic credits. + """ + costInCredits: Float + """ + The length of this starship in meters. + """ + length: Float + """ + The number of personnel needed to run or pilot this starship. + """ + crew: String + """ + The number of non-essential people this starship can transport. + """ + passengers: String + """ + The maximum speed of this starship in atmosphere. null if this starship is + incapable of atmosphering flight. + """ + maxAtmospheringSpeed: Int + """ + The class of this starships hyperdrive. + """ + hyperdriveRating: Float + """ + The Maximum number of Megalights this starship can travel in a standard hour. + A "Megalight" is a standard unit of distance and has never been defined before + within the Star Wars universe. This figure is only really useful for measuring + the difference in speed of starships. We can assume it is similar to AU, the + distance between our Sun (Sol) and Earth. + """ + MGLT: Int + """ + The maximum number of kilograms that this starship can transport. + """ + cargoCapacity: Float + """ + The maximum length of time that this starship can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): StarshipPilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): StarshipFilmsConnection + """ + The ISO 8601 date format of the time that this resource was created. + """ + created: String + """ + The ISO 8601 date format of the time that this resource was edited. + """ + edited: String + """ + The ID of an object + """ + id: ID! +} + +""" +A connection to a list of items. +""" +type StarshipPilotsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [StarshipPilotsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] +} + +""" +An edge in a connection. +""" +type StarshipPilotsEdge { + """ + The item at the end of the edge + """ + node: Person + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type StarshipFilmsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [StarshipFilmsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +""" +An edge in a connection. +""" +type StarshipFilmsEdge { + """ + The item at the end of the edge + """ + node: Film + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type PersonVehiclesConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [PersonVehiclesEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +""" +An edge in a connection. +""" +type PersonVehiclesEdge { + """ + The item at the end of the edge + """ + node: Vehicle + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A single transport craft that does not have hyperdrive capability +""" +type Vehicle implements Node { + """ + The name of this vehicle. The common name, such as "Sand Crawler" or "Speeder + bike". + """ + name: String + """ + The model or official name of this vehicle. Such as "All-Terrain Attack + Transport". + """ + model: String + """ + The class of this vehicle, such as "Wheeled" or "Repulsorcraft". + """ + vehicleClass: String + """ + The manufacturers of this vehicle. + """ + manufacturers: [String] + """ + The cost of this vehicle new, in Galactic Credits. + """ + costInCredits: Int + """ + The length of this vehicle in meters. + """ + length: Float + """ + The number of personnel needed to run or pilot this vehicle. + """ + crew: String + """ + The number of non-essential people this vehicle can transport. + """ + passengers: String + """ + The maximum speed of this vehicle in atmosphere. + """ + maxAtmospheringSpeed: Int + """ + The maximum number of kilograms that this vehicle can transport. + """ + cargoCapacity: Int + """ + The maximum length of time that this vehicle can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): VehiclePilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): VehicleFilmsConnection + """ + The ISO 8601 date format of the time that this resource was created. + """ + created: String + """ + The ISO 8601 date format of the time that this resource was edited. + """ + edited: String + """ + The ID of an object + """ + id: ID! +} + +""" +A connection to a list of items. +""" +type VehiclePilotsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [VehiclePilotsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] +} + +""" +An edge in a connection. +""" +type VehiclePilotsEdge { + """ + The item at the end of the edge + """ + node: Person + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type VehicleFilmsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [VehicleFilmsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +""" +An edge in a connection. +""" +type VehicleFilmsEdge { + """ + The item at the end of the edge + """ + node: Film + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type PlanetFilmsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [PlanetFilmsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +""" +An edge in a connection. +""" +type PlanetFilmsEdge { + """ + The item at the end of the edge + """ + node: Film + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type FilmSpeciesConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [FilmSpeciesEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] +} + +""" +An edge in a connection. +""" +type FilmSpeciesEdge { + """ + The item at the end of the edge + """ + node: Species + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type FilmStarshipsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [FilmStarshipsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +""" +An edge in a connection. +""" +type FilmStarshipsEdge { + """ + The item at the end of the edge + """ + node: Starship + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type FilmVehiclesConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [FilmVehiclesEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +""" +An edge in a connection. +""" +type FilmVehiclesEdge { + """ + The item at the end of the edge + """ + node: Vehicle + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type FilmCharactersConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [FilmCharactersEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + characters: [Person] +} + +""" +An edge in a connection. +""" +type FilmCharactersEdge { + """ + The item at the end of the edge + """ + node: Person + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type FilmPlanetsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [FilmPlanetsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] +} + +""" +An edge in a connection. +""" +type FilmPlanetsEdge { + """ + The item at the end of the edge + """ + node: Planet + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type PeopleConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [PeopleEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] +} + +""" +An edge in a connection. +""" +type PeopleEdge { + """ + The item at the end of the edge + """ + node: Person + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type PlanetsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [PlanetsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] +} + +""" +An edge in a connection. +""" +type PlanetsEdge { + """ + The item at the end of the edge + """ + node: Planet + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type SpeciesConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [SpeciesEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] +} + +""" +An edge in a connection. +""" +type SpeciesEdge { + """ + The item at the end of the edge + """ + node: Species + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type StarshipsConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [StarshipsEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +""" +An edge in a connection. +""" +type StarshipsEdge { + """ + The item at the end of the edge + """ + node: Starship + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +A connection to a list of items. +""" +type VehiclesConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + Information to aid in pagination. + """ + edges: [VehiclesEdge] + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +""" +An edge in a connection. +""" +type VehiclesEdge { + """ + The item at the end of the edge + """ + node: Vehicle + """ + A cursor for use in pagination + """ + cursor: String! +} + +""" +The `Date` scalar type represents date format. +""" +scalar Date + +schema { + query: Root +} diff --git a/tests/normalized-cache/src/commonMain/graphql/operations.graphql b/tests/normalized-cache/src/commonMain/graphql/main/operations.graphql similarity index 100% rename from tests/normalized-cache/src/commonMain/graphql/operations.graphql rename to tests/normalized-cache/src/commonMain/graphql/main/operations.graphql diff --git a/tests/normalized-cache/src/commonMain/graphql/schema.graphqls b/tests/normalized-cache/src/commonMain/graphql/main/schema.graphqls similarity index 100% rename from tests/normalized-cache/src/commonMain/graphql/schema.graphqls rename to tests/normalized-cache/src/commonMain/graphql/main/schema.graphqls diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/CharacterDetails.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/CharacterDetails.graphql new file mode 100644 index 00000000..d7cee4aa --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/CharacterDetails.graphql @@ -0,0 +1,10 @@ +query CharacterDetails($id: ID!) { + character(id: $id) { + ... on Human { + name + birthDate + appearsIn + firstAppearsIn + } + } +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/CharacterNameById.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/CharacterNameById.graphql new file mode 100644 index 00000000..894763e9 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/CharacterNameById.graphql @@ -0,0 +1,5 @@ +query CharacterNameById($id: ID!) { + character(id: $id) { + name + } +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/CharacterWithBirthDate.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/CharacterWithBirthDate.graphql new file mode 100644 index 00000000..476422ca --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/CharacterWithBirthDate.graphql @@ -0,0 +1,5 @@ +query CharacterWithBirthDate($id: ID!) { + character(id: $id) { + birthDate + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/CreateReviewMutation.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/CreateReviewMutation.graphql new file mode 100644 index 00000000..9d7b0e3f --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/CreateReviewMutation.graphql @@ -0,0 +1,7 @@ +mutation CreateReview($episode: Episode!, $review: ReviewInput!) { + createReview(episode: $episode, review: $review) { + id + stars + commentary + } +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroName.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroName.graphql new file mode 100644 index 00000000..241fc927 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroName.graphql @@ -0,0 +1,5 @@ +query EpisodeHeroName($episode: Episode) { + hero(episode: $episode) { + name + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroNameWithId.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroNameWithId.graphql new file mode 100644 index 00000000..9c3da6fe --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroNameWithId.graphql @@ -0,0 +1,6 @@ +query EpisodeHeroNameWithId($episode: Episode) { + hero(episode: $episode) { + id + name + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroWithDates.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroWithDates.graphql new file mode 100644 index 00000000..e479882e --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroWithDates.graphql @@ -0,0 +1,7 @@ +query EpisodeHeroWithDates($episode: Episode = null) { + hero(episode: $episode) { + heroName: name + birthDate + showUpDates: appearanceDates + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroWithInlineFragment.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroWithInlineFragment.graphql new file mode 100644 index 00000000..e3e6e648 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/EpisodeHeroWithInlineFragment.graphql @@ -0,0 +1,16 @@ +query EpisodeHeroWithInlineFragment { + hero { + name + friends { + ... on Human { + id + name + height + } + ... on Droid { + name + primaryFunction + } + } + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsDirectives.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsDirectives.graphql new file mode 100644 index 00000000..e188d00f --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsDirectives.graphql @@ -0,0 +1,8 @@ +query HeroAndFriendsDirectives($episode: Episode, $includeName: Boolean!, $skipFriends: Boolean!) { + hero(episode: $episode) { + name @include(if: $includeName) + friends @skip(if: $skipFriends) { + name + } + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsName.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsName.graphql new file mode 100644 index 00000000..04cef245 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsName.graphql @@ -0,0 +1,8 @@ +query HeroAndFriendsNames($episode: Episode) { + hero(episode: $episode) { + name + friends { + name + } + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsNameWithIds.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsNameWithIds.graphql new file mode 100644 index 00000000..6739515f --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsNameWithIds.graphql @@ -0,0 +1,10 @@ +query HeroAndFriendsNamesWithIDs($episode: Episode) { + hero(episode: $episode) { + id + name + friends { + id + name + } + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsNameWithIdsForParentOnly.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsNameWithIdsForParentOnly.graphql new file mode 100644 index 00000000..51dea585 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsNameWithIdsForParentOnly.graphql @@ -0,0 +1,9 @@ +query HeroAndFriendsNamesWithIDForParentOnly($episode: Episode) { + hero(episode: $episode) { + id + name + friends { + name + } + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsWithFragments.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsWithFragments.graphql new file mode 100644 index 00000000..6d2f1784 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAndFriendsWithFragments.graphql @@ -0,0 +1,5 @@ +query HeroAndFriendsWithFragments { + hero { + ... HeroWithFriendsFragment + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAppearsIn.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAppearsIn.graphql new file mode 100644 index 00000000..d0802c1c --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroAppearsIn.graphql @@ -0,0 +1,5 @@ +query HeroAppearsIn { + hero { + appearsIn + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroName.gql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroName.gql new file mode 100644 index 00000000..27ac1920 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroName.gql @@ -0,0 +1,5 @@ +query HeroName { + hero { + name + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroNameWithEnums.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroNameWithEnums.graphql new file mode 100644 index 00000000..8d467452 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroNameWithEnums.graphql @@ -0,0 +1,7 @@ +query HeroNameWithEnums { + hero { + name + firstAppearsIn + appearsIn + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroNameWithId.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroNameWithId.graphql new file mode 100644 index 00000000..22a25279 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroNameWithId.graphql @@ -0,0 +1,6 @@ +query HeroNameWithId { + hero { + id + name + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroParentTypeDependentField.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroParentTypeDependentField.graphql new file mode 100644 index 00000000..b3f31beb --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroParentTypeDependentField.graphql @@ -0,0 +1,21 @@ +query HeroParentTypeDependentField($episode: Episode) { + hero(episode: $episode) { + name + ... on Human { + friends { + name + ... on Human { + height(unit: FOOT) + } + } + } + ... on Droid { + friends { + name + ... on Human { + height(unit: METER) + } + } + } + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroTypeDependentAliasedField.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroTypeDependentAliasedField.graphql new file mode 100644 index 00000000..1ab5a065 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroTypeDependentAliasedField.graphql @@ -0,0 +1,10 @@ +query HeroTypeDependentAliasedField($episode: Episode) { + hero(episode: $episode) { + ... on Human { + property: homePlanet + } + ... on Droid { + property: primaryFunction + } + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroWithFriendsFragment.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroWithFriendsFragment.graphql new file mode 100644 index 00000000..fe9d1c8c --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HeroWithFriendsFragment.graphql @@ -0,0 +1,7 @@ +fragment HeroWithFriendsFragment on Character { + id + name + friends { + ... HumanWithIdFragment + } +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/HumanWithIdFragment.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/HumanWithIdFragment.graphql new file mode 100644 index 00000000..6902fb62 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/HumanWithIdFragment.graphql @@ -0,0 +1,4 @@ +fragment HumanWithIdFragment on Human { + id + name +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/Instant.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/Instant.graphql new file mode 100644 index 00000000..07ac2a35 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/Instant.graphql @@ -0,0 +1,3 @@ +query Instant { + instant +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/JsonScalar.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/JsonScalar.graphql new file mode 100644 index 00000000..0fd5f530 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/JsonScalar.graphql @@ -0,0 +1,3 @@ +query GetJsonScalar { + json +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/NonNullHero.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/NonNullHero.graphql new file mode 100644 index 00000000..6d479e47 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/NonNullHero.graphql @@ -0,0 +1,5 @@ +query NonNullHero { + hero @nonnull { + name + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/NullableHero.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/NullableHero.graphql new file mode 100644 index 00000000..f8438cb8 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/NullableHero.graphql @@ -0,0 +1,5 @@ +query NullableHero { + hero { + name + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/ReviewsByEpisode.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/ReviewsByEpisode.graphql new file mode 100644 index 00000000..c7054382 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/ReviewsByEpisode.graphql @@ -0,0 +1,7 @@ +query ReviewsByEpisode($episode: Episode!) { + reviews(episode: $episode) { + id + stars + commentary + } +} \ No newline at end of file diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/SameHeroTwice.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/SameHeroTwice.graphql new file mode 100644 index 00000000..2480a0e4 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/SameHeroTwice.graphql @@ -0,0 +1,8 @@ +query SameHeroTwice { + hero { + name + } + r2: hero { + appearsIn + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/SearchHero.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/SearchHero.graphql new file mode 100644 index 00000000..bdd5c6bf --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/SearchHero.graphql @@ -0,0 +1,15 @@ +query SearchHero($text: String) { + search(text: $text) { + __typename + ... on Character { + __typename + name + ... on Human { + homePlanet + } + ... on Droid { + primaryFunction + } + } + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/StartshipById.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/StartshipById.graphql new file mode 100644 index 00000000..0b0edc62 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/StartshipById.graphql @@ -0,0 +1,7 @@ +query StarshipById($id: ID!) { + starship(id: $id) { + id, + name, + coordinates + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/UpdateReviewMutation.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/UpdateReviewMutation.graphql new file mode 100644 index 00000000..ecac514f --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/UpdateReviewMutation.graphql @@ -0,0 +1,7 @@ +mutation UpdateReview($id: ID!, $review: ReviewInput!) { + updateReview(id: $id, review: $review) { + id + stars + commentary + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/UpdateReviewWithoutVariableMutation.graphql b/tests/normalized-cache/src/commonMain/graphql/normalizer/UpdateReviewWithoutVariableMutation.graphql new file mode 100644 index 00000000..3d5c2c11 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/UpdateReviewWithoutVariableMutation.graphql @@ -0,0 +1,11 @@ +mutation UpdateReviewWithoutVariable { + updateReview(id: "0", review: { + stars: 5 + commentary: "Great" + favoriteColor: {} + }) { + id + stars + commentary + } +} diff --git a/tests/normalized-cache/src/commonMain/graphql/normalizer/schema.graphqls b/tests/normalized-cache/src/commonMain/graphql/normalizer/schema.graphqls new file mode 100644 index 00000000..10c76212 --- /dev/null +++ b/tests/normalized-cache/src/commonMain/graphql/normalizer/schema.graphqls @@ -0,0 +1,344 @@ +""" +The query type, represents all of the entry points into our object graph +""" +type Query { + + hero (episode: Episode): Character + + heroDetailQuery: Character + + heroWithReview (episode: Episode, review: ReviewInput): Human + + reviews (episode: Episode!): [Review] + + search (text: String): [SearchResult] + + character (id: ID!): Character + + droid (id: ID!): Droid + + human (id: ID!): Human + + starship (id: ID!): Starship + + json: Json + + instant: Instant! +} + +""" +The episodes in the Star Wars trilogy +""" +enum Episode { + """ + Star Wars Episode IV: A New Hope, released in 1977. + """ + NEWHOPE + """ + Star Wars Episode V: The Empire Strikes Back, released in 1980. + """ + EMPIRE + """ + Star Wars Episode VI: Return of the Jedi, released in 1983. + """ + JEDI +} + +""" +A character from the Star Wars universe +""" +interface Character { + """ + The ID of the character + """ + id: ID! + """ + The name of the character + """ + name: String! + """ + The friends of the character, or an empty list if they have none + """ + friends: [Character] + """ + The friends of the character exposed as a connection with edges + """ + friendsConnection (first: Int, after: ID): FriendsConnection! + """ + The movies this character appears in + """ + appearsIn: [Episode]! + """ + The movie this character first appears in + """ + firstAppearsIn: Episode! + """ + The date character was born. + """ + birthDate: Date! + """ + The date character was born. + """ + fieldWithUnsupportedType: UnsupportedType! + """ + The dates of appearances + """ + appearanceDates: [Date!]! +} + +""" +The `Date` scalar type represents date format. +""" +scalar Date + +scalar Instant + +""" +UnsupportedType for testing +""" +scalar UnsupportedType + +""" +A connection object for a character's friends +""" +type FriendsConnection { + """ + The total number of friends + """ + totalCount: Int + """ + The edges for each of the character's friends. + """ + edges: [FriendsEdge] + """ + A list of the friends, as a convenience when edges are not needed. + """ + friends: [Character] + """ + Information for paginating this connection + """ + pageInfo: PageInfo! +} + +""" +An edge object for a character's friends +""" +type FriendsEdge { + """ + A cursor used for pagination + """ + cursor: ID! + """ + The character represented by this friendship edge + """ + node: Character +} + +""" +Information for paginating this connection +""" +type PageInfo { + + startCursor: ID + + endCursor: ID + + hasNextPage: Boolean! +} + +""" +Represents a review for a movie +""" +type Review { + """ + The ID of the review + """ + id: ID! + """ + The number of stars this review gave, 1-5 + """ + stars: Int! + """ + Comment about the movie + """ + commentary: String +} + + +union SearchResult = Human|Droid|Starship +""" +A humanoid creature from the Star Wars universe +""" +type Human implements Character { + """ + The ID of the human + """ + id: ID! + """ + What this human calls themselves + """ + name: String! + """ + The home planet of the human, or null if unknown + """ + homePlanet: String + """ + Height in the preferred unit, default is meters + """ + height (unit: LengthUnit = METER): Float + """ + Mass in kilograms, or null if unknown + """ + mass: Float + """ + This human's friends, or an empty list if they have none + """ + friends: [Character] + """ + The friends of the human exposed as a connection with edges + """ + friendsConnection (first: Int, after: ID): FriendsConnection! + """ + The movies this human appears in + """ + appearsIn: [Episode]! + """ + The movie this character first appears in + """ + firstAppearsIn: Episode! + """ + The date character was born. + """ + birthDate: Date! + """ + The date character was born. + """ + fieldWithUnsupportedType: UnsupportedType! + """ + The dates of appearances + """ + appearanceDates: [Date!]! + """ + A list of starships this person has piloted, or an empty list if none + """ + starships: [Starship] +} + +""" +Units of height +""" +enum LengthUnit { + """ + The standard unit around the world + """ + METER + """ + Primarily used in the United States + """ + FOOT +} + + +type Starship { + """ + The ID of the starship + """ + id: ID! + """ + The name of the starship + """ + name: String! + """ + Length of the starship, along the longest axis + """ + length (unit: LengthUnit = METER): Float + + coordinates: [[Float!]!] +} + +""" +An autonomous mechanical character in the Star Wars universe +""" +type Droid implements Character { + """ + The ID of the droid + """ + id: ID! + """ + What others call this droid + """ + name: String! + """ + This droid's friends, or an empty list if they have none + """ + friends: [Character] + """ + The friends of the droid exposed as a connection with edges + """ + friendsConnection (first: Int, after: ID): FriendsConnection! + """ + The movies this droid appears in + """ + appearsIn: [Episode]! + """ + The movie this character first appears in + """ + firstAppearsIn: Episode! + """ + The date droid was created. + """ + birthDate: Date! + """ + The date character was born. + """ + fieldWithUnsupportedType: UnsupportedType! + """ + The dates of appearances + """ + appearanceDates: [Date!]! + """ + This droid's primary function + """ + primaryFunction: String +} + +""" +The mutation type, represents all updates we can make to our data +""" +type Mutation { + + createReview (episode: Episode, review: ReviewInput!): Review + + updateReview (id: ID!, review: ReviewInput!): Review +} + +""" +The input object sent when someone is creating a new review +""" +input ReviewInput { + """ + 0-5 stars + """ stars: Int! + """ + Comment about the movie, optional + """ commentary: String + """ + Favorite color, optional + """ favoriteColor: ColorInput! +} + +""" +The input object sent when passing in a color +""" +input ColorInput { + red: Int! = 1 + green: Float = 0.0 + blue: Float! = 1.5 +} + +schema { + query: Query + mutation: Mutation +} + +scalar Json diff --git a/tests/normalized-cache/src/commonTest/kotlin/BasicTest.kt b/tests/normalized-cache/src/commonTest/kotlin/BasicTest.kt new file mode 100644 index 00000000..a7add521 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/BasicTest.kt @@ -0,0 +1,211 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Query +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import httpcache.AllPlanetsQuery +import normalizer.EpisodeHeroNameQuery +import normalizer.HeroAndFriendsNamesQuery +import normalizer.HeroAndFriendsNamesWithIDForParentOnlyQuery +import normalizer.HeroAndFriendsNamesWithIDsQuery +import normalizer.HeroAppearsInQuery +import normalizer.SameHeroTwiceQuery +import normalizer.StarshipByIdQuery +import normalizer.type.Episode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +/** + * A series of high level cache tests that use NetworkOnly to cache a json and then retrieve it with CacheOnly and make + * sure everything works. + * + * The tests are simple and are most likely already covered by the other tests but it's kept here for consistency + * and maybe they'll catch something one day? + */ +class BasicTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheKeyGenerator = IdCacheKeyGenerator() + ) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + private fun basicTest( + resourceName: String, + query: Query, + block: ApolloResponse.() -> Unit, + ) = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8(resourceName)) + var response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + response.block() + response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + response.block() + } + + @Test + fun episodeHeroName() = basicTest( + "HeroNameResponse.json", + EpisodeHeroNameQuery(Episode.EMPIRE) + ) { + + assertFalse(hasErrors()) + assertEquals(data?.hero?.name, "R2-D2") + } + + @Test + @Throws(Exception::class) + fun heroAndFriendsNameResponse() = basicTest( + "HeroAndFriendsNameResponse.json", + HeroAndFriendsNamesQuery(Episode.JEDI) + ) { + + assertFalse(hasErrors()) + assertEquals(data?.hero?.name, "R2-D2") + assertEquals(data?.hero?.friends?.size, 3) + assertEquals(data?.hero?.friends?.get(0)?.name, "Luke Skywalker") + assertEquals(data?.hero?.friends?.get(1)?.name, "Han Solo") + assertEquals(data?.hero?.friends?.get(2)?.name, "Leia Organa") + } + + @Test + fun heroAndFriendsNamesWithIDs() = basicTest( + "HeroAndFriendsNameWithIdsResponse.json", + HeroAndFriendsNamesWithIDsQuery(Episode.NEWHOPE) + ) { + + assertFalse(hasErrors()) + assertEquals(data?.hero?.id, "2001") + assertEquals(data?.hero?.name, "R2-D2") + assertEquals(data?.hero?.friends?.size, 3) + assertEquals(data?.hero?.friends?.get(0)?.id, "1000") + assertEquals(data?.hero?.friends?.get(0)?.name, "Luke Skywalker") + assertEquals(data?.hero?.friends?.get(1)?.id, "1002") + assertEquals(data?.hero?.friends?.get(1)?.name, "Han Solo") + assertEquals(data?.hero?.friends?.get(2)?.id, "1003") + assertEquals(data?.hero?.friends?.get(2)?.name, "Leia Organa") + } + + @Test + @Throws(Exception::class) + fun heroAndFriendsNameWithIdsForParentOnly() = basicTest( + "HeroAndFriendsNameWithIdsParentOnlyResponse.json", + HeroAndFriendsNamesWithIDForParentOnlyQuery(Episode.NEWHOPE) + ) { + + assertFalse(hasErrors()) + assertEquals(data?.hero?.id, "2001") + assertEquals(data?.hero?.name, "R2-D2") + assertEquals(data?.hero?.friends?.size, 3) + assertEquals(data?.hero?.friends?.get(0)?.name, "Luke Skywalker") + assertEquals(data?.hero?.friends?.get(1)?.name, "Han Solo") + assertEquals(data?.hero?.friends?.get(2)?.name, "Leia Organa") + } + + @Test + @Throws(Exception::class) + fun heroAppearsInResponse() = basicTest( + "HeroAppearsInResponse.json", + HeroAppearsInQuery() + ) { + + assertFalse(hasErrors()) + assertEquals(data?.hero?.appearsIn?.size, 3) + assertEquals(data?.hero?.appearsIn?.get(0), Episode.NEWHOPE) + assertEquals(data?.hero?.appearsIn?.get(1), Episode.EMPIRE) + assertEquals(data?.hero?.appearsIn?.get(2), Episode.JEDI) + } + + @Test + fun heroAppearsInResponseWithNulls() = basicTest( + "HeroAppearsInResponseWithNulls.json", + HeroAppearsInQuery() + ) { + + assertFalse(hasErrors()) + assertEquals(data?.hero?.appearsIn?.size, 6) + assertNull(data?.hero?.appearsIn?.get(0)) + assertEquals(data?.hero?.appearsIn?.get(1), Episode.NEWHOPE) + assertEquals(data?.hero?.appearsIn?.get(2), Episode.EMPIRE) + assertNull(data?.hero?.appearsIn?.get(3)) + assertEquals(data?.hero?.appearsIn?.get(4), Episode.JEDI) + assertNull(data?.hero?.appearsIn?.get(5)) + } + + + @Test + fun requestingTheSameFieldTwiceWithAnAlias() = basicTest( + "SameHeroTwiceResponse.json", + SameHeroTwiceQuery() + ) { + assertFalse(hasErrors()) + assertEquals(data?.hero?.name, "R2-D2") + assertEquals(data?.r2?.appearsIn?.size, 3) + assertEquals(data?.r2?.appearsIn?.get(0), Episode.NEWHOPE) + assertEquals(data?.r2?.appearsIn?.get(1), Episode.EMPIRE) + assertEquals(data?.r2?.appearsIn?.get(2), Episode.JEDI) + } + + @Test + fun cacheResponseWithNullableFields() = basicTest( + "AllPlanetsNullableField.json", + AllPlanetsQuery() + ) { + assertFalse(hasErrors()) + } + + @Test + fun readList() = basicTest( + "HeroAndFriendsNameWithIdsResponse.json", + HeroAndFriendsNamesWithIDsQuery(Episode.NEWHOPE) + ) { + assertEquals(data?.hero?.id, "2001") + assertEquals(data?.hero?.name, "R2-D2") + assertEquals(data?.hero?.friends?.size, 3) + assertEquals(data?.hero?.friends?.get(0)?.id, "1000") + assertEquals(data?.hero?.friends?.get(0)?.name, "Luke Skywalker") + assertEquals(data?.hero?.friends?.get(1)?.id, "1002") + assertEquals(data?.hero?.friends?.get(1)?.name, "Han Solo") + assertEquals(data?.hero?.friends?.get(2)?.id, "1003") + assertEquals(data?.hero?.friends?.get(2)?.name, "Leia Organa") + } + + @Test + fun listOfList() = basicTest( + "StarshipByIdResponse.json", + StarshipByIdQuery("Starship1") + ) { + assertEquals(data?.starship?.name, "SuperRocket") + assertEquals(data?.starship?.coordinates, + listOf( + listOf(100.0, 200.0), + listOf(300.0, 400.0), + listOf(500.0, 600.0) + ) + ) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/CacheFlagsTest.kt b/tests/normalized-cache/src/commonTest/kotlin/CacheFlagsTest.kt new file mode 100644 index 00000000..73cbec27 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/CacheFlagsTest.kt @@ -0,0 +1,131 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloRequest +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Error +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.exception.CacheMissException +import com.apollographql.apollo.interceptor.ApolloInterceptor +import com.apollographql.apollo.interceptor.ApolloInterceptorChain +import com.apollographql.apollo.testing.QueueTestNetworkTransport +import com.apollographql.apollo.testing.enqueueTestResponse +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.ApolloCacheHeaders +import com.apollographql.cache.normalized.api.CacheHeaders +import com.apollographql.cache.normalized.cacheHeaders +import com.apollographql.cache.normalized.doNotStore +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.cache.normalized.storePartialResponses +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import normalizer.HeroNameQuery +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +class CacheFlagsTest { + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private fun setUp() { + store = ApolloStore(MemoryCacheFactory()) + apolloClient = ApolloClient.Builder().networkTransport(QueueTestNetworkTransport()).store(store).build() + } + + @Test + fun doNotStore() = runTest(before = { setUp() }) { + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + apolloClient.enqueueTestResponse(query, data) + + apolloClient.query(query).doNotStore(true).execute() + + // Since the previous request was not stored, this should fail + assertIs( + apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute().exception + ) + } + + @Test + fun testEvictAfterRead() = runTest(before = { setUp() }) { + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + apolloClient.enqueueTestResponse(query, data) + + // Store the data + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // This should work and evict the entries + val response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheOnly) + .cacheHeaders(CacheHeaders.builder().addHeader(ApolloCacheHeaders.EVICT_AFTER_READ, "true").build()) + .execute() + + assertEquals("R2-D2", response.data?.hero?.name) + + // Second time should fail + assertIs( + apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute().exception + ) + } + + private val partialResponseData = HeroNameQuery.Data(null) + private val partialResponseErrors = listOf( + Error.Builder(message = "An error Happened") + .locations(listOf(Error.Location(0, 0))) + .build() + ) + + + @Test + fun partialResponsesAreNotStored() = runTest(before = { setUp() }) { + val query = HeroNameQuery() + apolloClient.enqueueTestResponse(query, partialResponseData, partialResponseErrors) + + // this should not store the response + apolloClient.query(query).execute() + + assertIs( + apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute().exception + ) + } + + @Test + fun storePartialResponse() = runTest(before = { setUp() }) { + val query = HeroNameQuery() + apolloClient.enqueueTestResponse(query, partialResponseData, partialResponseErrors) + + // this should store the response + apolloClient.query(query).storePartialResponses(true).execute() + + val response = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertNotNull(response.data) + } + + @Test + fun doNotStoreWhenSetInResponse() = runTest(before = { setUp() }) { + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + apolloClient = apolloClient.newBuilder().addInterceptor(object : ApolloInterceptor { + override fun intercept(request: ApolloRequest, chain: ApolloInterceptorChain): Flow> { + return chain.proceed(request).map { response -> + response.newBuilder().cacheHeaders(CacheHeaders.Builder().addHeader(ApolloCacheHeaders.DO_NOT_STORE, "").build()).build() + } + } + }).build() + apolloClient.enqueueTestResponse(query, data) + + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkFirst).execute() + + // Since the previous request was not stored, this should fail + assertIs( + apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute().exception + ) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/CacheResolverTest.kt b/tests/normalized-cache/src/commonTest/kotlin/CacheResolverTest.kt new file mode 100644 index 00000000..7aecbf74 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/CacheResolverTest.kt @@ -0,0 +1,39 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.api.CacheResolver +import com.apollographql.cache.normalized.api.DefaultCacheResolver +import com.apollographql.cache.normalized.api.ResolverContext +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import normalizer.HeroNameQuery +import kotlin.test.Test +import kotlin.test.assertEquals + +class CacheResolverTest { + @Test + fun cacheResolverCanResolveQuery() = runTest { + val resolver = object : CacheResolver { + override fun resolveField(context: ResolverContext): Any? { + return when (context.field.name) { + "hero" -> mapOf("name" to "Luke") + else -> DefaultCacheResolver.resolveField(context) + } + } + } + val apolloClient = ApolloClient.Builder().serverUrl(serverUrl = "") + .store( + ApolloStore( + normalizedCacheFactory = MemoryCacheFactory(), + cacheResolver = resolver + ) + ) + .build() + + val response = apolloClient.query(HeroNameQuery()).execute() + + assertEquals("Luke", response.data?.hero?.name) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/CancelTest.kt b/tests/normalized-cache/src/commonTest/kotlin/CancelTest.kt new file mode 100644 index 00000000..543619bd --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/CancelTest.kt @@ -0,0 +1,54 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import normalizer.EpisodeHeroNameQuery +import normalizer.type.Episode +import kotlin.test.Test + +class CancelTest { + private lateinit var mockServer: MockServer + + private fun setUp() { + mockServer = MockServer() + } + + private suspend fun tearDown() { + mockServer.close() + } + + @Test + fun cancelFlow() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("EpisodeHeroNameResponse.json")) + val apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).build() + + val job = launch { + delay(100) + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)).execute() + error("The Flow should have been canceled before reaching that state") + } + job.cancel() + job.join() + } + + @Test + fun canCancelQueryCacheAndNetwork() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("EpisodeHeroNameResponse.json"), 500) + val apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).normalizedCache(MemoryCacheFactory()).build() + + val job = launch { + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)).fetchPolicy(FetchPolicy.CacheAndNetwork).toFlow().toList() + } + delay(100) + job.cancel() + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/ExceptionsTest.kt b/tests/normalized-cache/src/commonTest/kotlin/ExceptionsTest.kt new file mode 100644 index 00000000..09c09f6e --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/ExceptionsTest.kt @@ -0,0 +1,144 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.exception.ApolloHttpException +import com.apollographql.apollo.exception.ApolloNetworkException +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueError +import com.apollographql.mockserver.enqueueString +import kotlinx.coroutines.flow.toList +import normalizer.HeroAndFriendsNamesQuery +import normalizer.HeroNameQuery +import normalizer.type.Episode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ExceptionsTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + + private suspend fun setUp() { + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + @Test + fun whenQueryAndMalformedNetworkResponseAssertException() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString("malformed") + + val response = apolloClient.query(HeroNameQuery()).execute() + assertTrue(response.exception != null) + } + + @Test + fun whenHttpErrorAssertExecuteFails() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueError(statusCode = 404) + + val response = apolloClient.query(HeroNameQuery()).execute() + val exception = response.exception + assertTrue(exception is ApolloHttpException) + assertEquals(404, exception.statusCode) + } + + @Test + fun whenNetworkErrorAssertApolloNetworkException() = runTest { + apolloClient = ApolloClient.Builder().serverUrl("http://badhost/").build() + + val response = apolloClient.query(HeroNameQuery()).execute() + assertTrue(response.exception is ApolloNetworkException) + } + + @Test + @Suppress("DEPRECATION") + fun toFlowThrows() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString("malformed") + + val throwingClient = apolloClient.newBuilder().build() + var result = kotlin.runCatching { + throwingClient.query(HeroNameQuery()).toFlowV3().toList() + } + assertNotNull(result.exceptionOrNull()) + } + + @Test + @Suppress("DEPRECATION") + fun toFlowDoesNotThrowOnV3() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(""" + { + "errors": [ + { + "message": "An error", + "locations": [ + { + "line": 1, + "column": 1 + } + ] + } + ] + } + """.trimIndent() + ) + val errorClient = apolloClient.newBuilder().build() + val response = errorClient.query(HeroNameQuery()).toFlowV3().toList() + assertTrue(response.first().errors?.isNotEmpty() ?: false) + } + + private val PARTIAL_DATA_RESPONSE = """ + { + "data": { + "hero": { + "name": "R2-D2", + "friends": null + } + }, + "errors": [ + { + "message": "Could not get friends", + "locations": [ + { + "line": 1, + "column": 1 + } + ], + "path": [ + "hero", + "friends" + ] + } + ] + } + """.trimIndent() + + @Test + @Suppress("DEPRECATION") + fun v3ExceptionHandlingKeepsPartialData() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(PARTIAL_DATA_RESPONSE) + val errorClient = apolloClient.newBuilder() + .build() + val response = errorClient.query(HeroAndFriendsNamesQuery(Episode.EMPIRE)).executeV3() + assertNotNull(response.data) + assertTrue(response.errors?.isNotEmpty() == true) + } + + @Test + @Suppress("DEPRECATION") + fun v3ExceptionHandlingKeepsPartialDataWithCache() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(PARTIAL_DATA_RESPONSE.trimIndent()) + val errorClient = apolloClient.newBuilder() + .normalizedCache(MemoryCacheFactory(1024 * 1024)) + .build() + val response = errorClient.query(HeroAndFriendsNamesQuery(Episode.EMPIRE)).executeV3() + assertNotNull(response.data) + assertTrue(response.errors?.isNotEmpty() == true) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/FetchPolicyTest.kt b/tests/normalized-cache/src/commonTest/kotlin/FetchPolicyTest.kt new file mode 100644 index 00000000..86672212 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/FetchPolicyTest.kt @@ -0,0 +1,691 @@ +@file:OptIn(ApolloInternal::class) +@file:Suppress("DEPRECATION") + +package test + +import app.cash.turbine.test +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.annotations.ApolloInternal +import com.apollographql.apollo.api.ApolloRequest +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.api.composeJsonResponse +import com.apollographql.apollo.api.json.buildJsonString +import com.apollographql.apollo.exception.ApolloCompositeException +import com.apollographql.apollo.exception.ApolloException +import com.apollographql.apollo.exception.ApolloHttpException +import com.apollographql.apollo.exception.CacheMissException +import com.apollographql.apollo.exception.JsonEncodingException +import com.apollographql.apollo.interceptor.ApolloInterceptor +import com.apollographql.apollo.interceptor.ApolloInterceptorChain +import com.apollographql.apollo.testing.assertNoElement +import com.apollographql.apollo.testing.awaitElement +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.CacheFirstInterceptor +import com.apollographql.cache.normalized.CacheOnlyInterceptor +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.CacheKey +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.isFromCache +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.refetchPolicyInterceptor +import com.apollographql.cache.normalized.store +import com.apollographql.cache.normalized.watch +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.awaitRequest +import com.apollographql.mockserver.enqueueError +import com.apollographql.mockserver.enqueueString +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import normalizer.CharacterNameByIdQuery +import normalizer.HeroNameQuery +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +class FetchPolicyTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore(MemoryCacheFactory()) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store = store).build() + } + + private fun tearDown() { + mockServer.close() + apolloClient.close() + } + + @Test + fun cacheFirst() = runTest(before = { setUp() }, after = { tearDown() }) { + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + mockServer.enqueueString(query.composeJsonResponse(data)) + + // First query should hit the network and save in cache + var response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkFirst) + .execute() + + assertNotNull(response.data) + assertFalse(response.isFromCache) + + // Second query should only hit the cache + response = apolloClient.query(query).execute() + + assertNotNull(response.data) + assertTrue(response.isFromCache) + + // Clear the store and offer a malformed response, we should get a composite error + store.clearAll() + mockServer.enqueueString("malformed") + apolloClient.query(query).execute().exception.let { + assertIs(it) + assertIs(it.suppressedExceptions.first()) + } + } + + @Test + fun cacheFirstExecuteThrowing() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().build() + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + mockServer.enqueueString(query.composeJsonResponse(data)) + + // First query should hit the network and save in cache + @Suppress("DEPRECATION") + var response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkFirst) + .executeV3() + + assertNotNull(response.data) + assertFalse(response.isFromCache) + + // Second query should only hit the cache + @Suppress("DEPRECATION") + response = apolloClient.query(query) + .executeV3() + + assertNotNull(response.data) + assertTrue(response.isFromCache) + + // Clear the store and offer a malformed response, we should get a composite error + store.clearAll() + mockServer.enqueueString("malformed") + try { + @Suppress("DEPRECATION") + apolloClient.query(query) + .executeV3() + fail("we expected the query to fail") + } catch (e: Exception) { + @Suppress("DEPRECATION") + assertIs(e) + } + } + + + @Test + fun cacheFirstToFlowThrowing() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().build() + + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + mockServer.enqueueString(query.composeJsonResponse(data)) + + // First query should hit the network and save in cache + @Suppress("DEPRECATION") + var responses = apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkFirst) + .toFlowV3() + + responses.test { + val response1 = awaitItem() + assertEquals(data, response1.data) + assertFalse(response1.isFromCache) + awaitComplete() + } + + // Second query should only hit the cache + @Suppress("DEPRECATION") + responses = apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheFirst) + .toFlowV3() + responses.test { + val response1 = awaitItem() + assertEquals(data, response1.data) + assertTrue(response1.isFromCache) + awaitComplete() + } + + // Clear the store and offer a malformed response, we should get a composite error + store.clearAll() + mockServer.enqueueString("malformed") + @Suppress("DEPRECATION") + responses = apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheFirst) + .toFlowV3() + responses.test { + @Suppress("DEPRECATION") + assertIs(awaitError()) + } + } + + @Test + fun networkFirst() = runTest(before = { setUp() }, after = { tearDown() }) { + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + + val call = apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkFirst) + + // First query should hit the network and save in cache + mockServer.enqueueString(query.composeJsonResponse(data)) + var response = call.execute() + + assertNotNull(response.data) + assertFalse(response.isFromCache) + + // Now data is cached but it shouldn't be used since network will go through + mockServer.enqueueString(query.composeJsonResponse(data)) + response = call.execute() + + assertNotNull(response.data) + assertFalse(response.isFromCache) + + // Network error -> we should hit now the cache + mockServer.enqueueString("malformed") + response = call.execute() + + assertNotNull(response.data) + assertTrue(response.isFromCache) + + // Network error and no cache -> we should get an error + mockServer.enqueueString("malformed") + store.clearAll() + + call.execute().exception.let { + assertIs(it) + assertIs(it.suppressedExceptions.first()) + } + } + + @Test + fun networkFirstExecuteThrowing() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().build() + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + + val call = apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkFirst) + + // First query should hit the network and save in cache + mockServer.enqueueString(query.composeJsonResponse(data)) + var response = call.execute() + + assertNotNull(response.data) + assertFalse(response.isFromCache) + + // Now data is cached but it shouldn't be used since network will go through + mockServer.enqueueString(query.composeJsonResponse(data)) + response = call.execute() + + assertNotNull(response.data) + assertFalse(response.isFromCache) + + // Network error -> we should hit now the cache + mockServer.enqueueString("malformed") + response = call.execute() + + assertNotNull(response.data) + assertTrue(response.isFromCache) + + // Network error and no cache -> we should get an error + mockServer.enqueueString("malformed") + store.clearAll() + try { + @Suppress("DEPRECATION") + call.executeV3() + fail("NETWORK_FIRST should throw the network exception if nothing is in the cache") + } catch (e: Exception) { + + } + } + + @Test + fun networkFirstToFlowThrowing() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().build() + + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + + val call = apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkFirst) + + // First query should hit the network and save in cache + mockServer.enqueueString(query.composeJsonResponse(data)) + @Suppress("DEPRECATION") + var responses = call.toFlowV3() + responses.test { + val response1 = awaitItem() + assertEquals(data, response1.data) + assertFalse(response1.isFromCache) + awaitComplete() + } + + // Now data is cached but it shouldn't be used since network will go through + mockServer.enqueueString(query.composeJsonResponse(data)) + @Suppress("DEPRECATION") + responses = call.toFlowV3() + responses.test { + val response1 = awaitItem() + assertEquals(data, response1.data) + assertFalse(response1.isFromCache) + awaitComplete() + } + + // Network error -> we should hit now the cache + mockServer.enqueueString("malformed") + @Suppress("DEPRECATION") + responses = call.toFlowV3() + responses.test { + val response1 = awaitItem() + assertEquals(data, response1.data) + assertTrue(response1.isFromCache) + awaitComplete() + } + + // Network error and no cache -> we should get an error + mockServer.enqueueString("malformed") + store.clearAll() + @Suppress("DEPRECATION") + responses = call.toFlowV3() + responses.test { + @Suppress("DEPRECATION") + assertIs(awaitError()) + } + } + + @Test + fun cacheOnly() = runTest(before = { setUp() }, after = { tearDown() }) { + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + + // First query should hit the network and save in cache + mockServer.enqueueString(query.composeJsonResponse(data)) + var response = apolloClient.query(query).execute() + + assertNotNull(response.data) + assertFalse(response.isFromCache) + + // Second query should only hit the cache + response = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute() + + // And make sure we don't read the network + assertNotNull(response.data) + assertTrue(response.isFromCache) + } + + @Test + fun networkOnly() = runTest(before = { setUp() }, after = { tearDown() }) { + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + + val call = apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly) + + // First query should hit the network and save in cache + mockServer.enqueueString(query.composeJsonResponse(data)) + val response = call.execute() + + assertNotNull(response.data) + assertFalse(response.isFromCache) + + // Offer a malformed response, it should fail + mockServer.enqueueString("malformed") + assertNotNull(call.execute().exception) + } + + @Test + fun networkOnlyThrowing() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().build() + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + + val call = apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly) + + // First query should hit the network and save in cache + mockServer.enqueueString(query.composeJsonResponse(data)) + @Suppress("DEPRECATION") + val response = call.executeV3() + + assertNotNull(response.data) + assertFalse(response.isFromCache) + + // Offer a malformed response, it should fail + mockServer.enqueueString("malformed") + try { + @Suppress("DEPRECATION") + call.executeV3() + fail("we expected a failure") + } catch (_: Exception) { + + } + } + + @Test + fun cacheAndNetwork() = runTest(before = { setUp() }, after = { tearDown() }) { + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + var caught: Throwable? = null + + // Initial state: everything fails + // Cache Error + Network Error => Error + mockServer.enqueueError(statusCode = 500) + apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).execute().exception.let { + assertIs(it) + assertIs(it.suppressedExceptions.first()) + } + + // Make the network return something + // Cache Error + Network Success => 2 responses + mockServer.enqueueString(query.composeJsonResponse(data)) + var responses = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).toFlow().catch { caught = it }.toList() + + assertNull(caught) + assertEquals(2, responses.size) + assertNull(responses[0].data) + assertIs(responses[0].exception) + assertNotNull(responses[1].data) + assertFalse(responses[1].isFromCache) + assertEquals("R2-D2", responses[1].data?.hero?.name) + + // Now cache is populated but make the network fail again + // Cache Success + Network Error => 1 response with cache value + 1 response with network exception + caught = null + mockServer.enqueueError(statusCode = 500) + responses = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).toFlow().catch { caught = it }.toList() + + assertNull(caught) + assertEquals(2, responses.size) + assertNotNull(responses[0].data) + assertTrue(responses[0].isFromCache) + assertEquals("R2-D2", responses[0].data?.hero?.name) + assertNull(responses[1].data) + assertIs(responses[1].exception) + + // Cache Success + Network Success => 2 responses + mockServer.enqueueString(query.composeJsonResponse(data)) + responses = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).toFlow().toList() + + assertEquals(2, responses.size) + assertNotNull(responses[0].data) + assertTrue(responses[0].isFromCache) + assertNotNull(responses[1].data) + assertFalse(responses[1].isFromCache) + } + + + private val refetchPolicyInterceptor = object : ApolloInterceptor { + var hasSeenValidResponse: Boolean = false + override fun intercept(request: ApolloRequest, chain: ApolloInterceptorChain): Flow> { + return if (!hasSeenValidResponse) { + CacheOnlyInterceptor.intercept(request, chain).onEach { + if (it.data != null) { + // We have valid data, we can now use the network + hasSeenValidResponse = true + } + } + } else { + // If for some reason we have a cache miss, get fresh data from the network + CacheFirstInterceptor.intercept(request, chain) + } + } + } + + @Test + fun cacheAndNetworkThrowing() = runTest(before = { setUp() }, after = { tearDown() }) { + apolloClient = apolloClient.newBuilder().build() + + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + var caught: Throwable? = null + // Initial state: everything fails + // Cache Error + Network Error => Error + mockServer.enqueueError(statusCode = 500) + + @Suppress("DEPRECATION") + assertFailsWith { + @Suppress("DEPRECATION") + apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheAndNetwork) + .toFlowV3() + .toList() + } + + // Make the network return something + // Cache Error + Network Success => 1 response (no exception) + mockServer.enqueueString(query.composeJsonResponse(data)) + @Suppress("DEPRECATION") + var responses = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork) + .toFlowV3().catch { caught = it }.toList() + + assertNull(caught) + assertEquals(1, responses.size) + assertNotNull(responses[0].data) + assertFalse(responses[0].isFromCache) + assertEquals("R2-D2", responses[0].data?.hero?.name) + + // Now cache is populated but make the network fail again + // Cache Success + Network Error => 1 response + 1 network exception + caught = null + mockServer.enqueueError(statusCode = 500) + @Suppress("DEPRECATION") + responses = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).toFlowV3().catch { caught = it }.toList() + + assertIs(caught) + assertEquals(1, responses.size) + assertNotNull(responses[0].data) + assertTrue(responses[0].isFromCache) + assertEquals("R2-D2", responses[0].data?.hero?.name) + + // Cache Success + Network Success => 1 response + mockServer.enqueueString(query.composeJsonResponse(data)) + @Suppress("DEPRECATION") + responses = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).toFlowV3().toList() + + assertEquals(2, responses.size) + assertNotNull(responses[0].data) + assertTrue(responses[0].isFromCache) + assertNotNull(responses[1].data) + assertFalse(responses[1].isFromCache) + } + + /** + * Uses a refetchPolicy that will not go to the network until it has seen a valid response + */ + @Test + fun customRefetchPolicy() = runTest(before = { setUp() }, after = { tearDown() }) { + val channel = Channel>() + + /** + * Start the watcher + */ + val operation1 = HeroNameQuery() + val job = launch { + apolloClient.query(operation1) + .fetchPolicy(FetchPolicy.CacheOnly) + .refetchPolicyInterceptor(refetchPolicyInterceptor) + .watch() + .collect { + // Don't send the first response, it's a cache miss + if (it.exception == null) channel.send(it) + } + } + + delay(200) + + /** + * Make a first query that is disjoint from the watcher + */ + val operation2 = CharacterNameByIdQuery("83") + mockServer.enqueueString( + buildJsonString { + operation2.composeJsonResponse( + this, + CharacterNameByIdQuery.Data( + CharacterNameByIdQuery.Character( + "Luke" + ) + ) + ) + } + ) + + apolloClient.query(operation2) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + + /** + * Because the query was disjoint, the watcher will see a cache miss and not receive anything. + * Because initially the refetchPolicy uses CacheOnly, no network request will be made + */ + channel.assertNoElement() + + mockServer.enqueueString( + buildJsonString { + operation1.composeJsonResponse( + this, + HeroNameQuery.Data( + HeroNameQuery.Hero( + "Leila" + ) + ) + ) + } + ) + + /** + * Now we query operation1 from the network and it should update the watcher automatically + */ + apolloClient.query(operation1) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + + var response = channel.awaitElement() + assertTrue(response.isFromCache) + assertEquals("Leila", response.data?.hero?.name) + + /** + * clear the cache and trigger the watcher again + */ + store.clearAll() + + mockServer.enqueueString( + buildJsonString { + operation1.composeJsonResponse( + this, + HeroNameQuery.Data( + HeroNameQuery.Hero( + "Chewbacca" + ) + ) + ) + } + ) + store.publish(setOf("${CacheKey.rootKey().key}.hero")) + + /** + * This time the watcher should do a network request + */ + response = channel.awaitElement() + assertFalse(response.isFromCache) + assertEquals("Chewbacca", response.data?.hero?.name) + + /** + * Check that 3 network requests have been made + */ + mockServer.awaitRequest() + mockServer.awaitRequest() + mockServer.awaitRequest() + + job.cancel() + channel.cancel() + } + + @Test + fun isFromCache() = runTest(before = { setUp() }, after = { tearDown() }) { + val query = HeroNameQuery() + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + mockServer.enqueueString(query.composeJsonResponse(data)) + + // NetworkOnly / hit + var response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + assertNotNull(response.data) + assertFalse(response.isFromCache) + + // CacheOnly / hit + response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + assertNotNull(response.data) + assertTrue(response.isFromCache) + + // CacheOnly / miss + store.clearAll() + response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + assertNull(response.data) + assertTrue(response.isFromCache) + + // NetworkOnly / miss + mockServer.enqueueString("malformed") + response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + assertNull(response.data) + assertFalse(response.isFromCache) + + // CacheFirst / miss / miss + store.clearAll() + mockServer.enqueueString("malformed") + var responses = apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheFirst) + .toFlow() + .toList() + assertTrue(responses[0].isFromCache) + assertFalse(responses[1].isFromCache) + + // NetworkFirst / miss / miss + store.clearAll() + mockServer.enqueueString("malformed") + responses = apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkFirst) + .toFlow() + .toList() + assertFalse(responses[0].isFromCache) + assertTrue(responses[1].isFromCache) + + // CacheAndNetwork / hit / hit + mockServer.enqueueString(query.composeJsonResponse(data)) + responses = apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheAndNetwork) + .toFlow() + .toList() + assertTrue(responses[0].isFromCache) + assertFalse(responses[1].isFromCache) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/IdCacheKeyGeneratorTest.kt b/tests/normalized-cache/src/commonTest/kotlin/IdCacheKeyGeneratorTest.kt index a7e3b76b..2d0200f1 100644 --- a/tests/normalized-cache/src/commonTest/kotlin/IdCacheKeyGeneratorTest.kt +++ b/tests/normalized-cache/src/commonTest/kotlin/IdCacheKeyGeneratorTest.kt @@ -11,6 +11,10 @@ import com.apollographql.cache.normalized.api.IdCacheKeyResolver import com.apollographql.cache.normalized.fetchPolicy import com.apollographql.cache.normalized.memory.MemoryCacheFactory import com.apollographql.cache.normalized.store +import main.GetUser2Query +import main.GetUserByIdQuery +import main.GetUsersByIDsQuery +import main.GetUsersQuery import kotlin.test.Test import kotlin.test.assertEquals diff --git a/tests/normalized-cache/src/commonTest/kotlin/JsonScalarTest.kt b/tests/normalized-cache/src/commonTest/kotlin/JsonScalarTest.kt new file mode 100644 index 00000000..93e1d313 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/JsonScalarTest.kt @@ -0,0 +1,64 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.AnyAdapter +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import normalizer.GetJsonScalarQuery +import normalizer.type.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class JsonScalarTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore(MemoryCacheFactory()) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()) + .store(store) + .addCustomScalarAdapter(Json.type, AnyAdapter) + .build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + // see https://github.com/apollographql/apollo-kotlin/issues/2854 + @Test + fun jsonScalar() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("JsonScalar.json")) + var response = apolloClient.query(GetJsonScalarQuery()).execute() + + assertFalse(response.hasErrors()) + var expectedMap = mapOf( + "obj" to mapOf("key" to "value"), + "list" to listOf(0, 1, 2) + ) + assertEquals(expectedMap, response.data!!.json) + + /** + * Update the json value, it should be replaced, not merged + */ + mockServer.enqueueString(testFixtureToUtf8("JsonScalarModified.json")) + apolloClient.query(GetJsonScalarQuery()).fetchPolicy(FetchPolicy.NetworkFirst).execute() + response = apolloClient.query(GetJsonScalarQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute() + + assertFalse(response.hasErrors()) + + expectedMap = mapOf( + "obj" to mapOf("key2" to "value2"), + ) + assertEquals(expectedMap, response.data!!.json) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/MemoryCacheTest.kt b/tests/normalized-cache/src/commonTest/kotlin/MemoryCacheTest.kt new file mode 100644 index 00000000..6b80fae0 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/MemoryCacheTest.kt @@ -0,0 +1,32 @@ +package test + +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.api.CacheHeaders +import com.apollographql.cache.normalized.api.DefaultRecordMerger +import com.apollographql.cache.normalized.api.Record +import com.apollographql.cache.normalized.memory.MemoryCache +import kotlinx.coroutines.delay +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MemoryCacheTest { + @Test + fun testDoesNotExpireBeforeMillis() = runTest { + val record = Record( + key = "key", + fields = mapOf( + "field" to "value" + ) + ) + val memoryCache = MemoryCache(expireAfterMillis = 200) + memoryCache.merge(record, CacheHeaders.NONE, DefaultRecordMerger) + + val cacheRecord = checkNotNull(memoryCache.loadRecord(record.key, CacheHeaders.NONE)) + assertEquals(record.key, cacheRecord.key) + assertEquals(record.fields, cacheRecord.fields) + + delay(250) + assertNull(memoryCache.loadRecord(record.key, CacheHeaders.NONE)) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/NormalizationTest.kt b/tests/normalized-cache/src/commonTest/kotlin/NormalizationTest.kt index 539c25cb..6cb39c15 100644 --- a/tests/normalized-cache/src/commonTest/kotlin/NormalizationTest.kt +++ b/tests/normalized-cache/src/commonTest/kotlin/NormalizationTest.kt @@ -8,6 +8,7 @@ import com.apollographql.cache.normalized.FetchPolicy import com.apollographql.cache.normalized.fetchPolicy import com.apollographql.cache.normalized.memory.MemoryCacheFactory import com.apollographql.cache.normalized.normalizedCache +import main.RepositoryListQuery import kotlin.test.Test import kotlin.test.assertEquals diff --git a/tests/normalized-cache/src/commonTest/kotlin/NormalizedCacheThreadingTest.kt b/tests/normalized-cache/src/commonTest/kotlin/NormalizedCacheThreadingTest.kt new file mode 100644 index 00000000..44ffc5c5 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/NormalizedCacheThreadingTest.kt @@ -0,0 +1,42 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.QueueTestNetworkTransport +import com.apollographql.apollo.testing.currentThreadId +import com.apollographql.apollo.testing.enqueueTestResponse +import com.apollographql.cache.normalized.api.NormalizedCache +import com.apollographql.cache.normalized.api.NormalizedCacheFactory +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import kotlinx.coroutines.test.runTest +import normalizer.CharacterNameByIdQuery +import kotlin.test.Test +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +class NormalizedCacheThreadingTest { + @Test + fun cacheCreationHappensInBackgroundThread() = runTest { + @Suppress("DEPRECATION") + val testThreadName = currentThreadId() + // No threading on js + if (testThreadName == "js") return@runTest + var cacheCreateThreadName: String? = null + val apolloClient = ApolloClient.Builder() + .networkTransport(QueueTestNetworkTransport()) + .normalizedCache(object : NormalizedCacheFactory() { + override fun create(): NormalizedCache { + @Suppress("DEPRECATION") + cacheCreateThreadName = currentThreadId() + return MemoryCacheFactory().create() + } + }).build() + assertNull(cacheCreateThreadName) + + val query = CharacterNameByIdQuery("") + apolloClient.enqueueTestResponse(query, CharacterNameByIdQuery.Data(CharacterNameByIdQuery.Character(""))) + apolloClient.query(query).execute() + println("cacheCreateThreadName: $cacheCreateThreadName") + assertNotEquals(testThreadName, cacheCreateThreadName) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/NormalizerTest.kt b/tests/normalized-cache/src/commonTest/kotlin/NormalizerTest.kt new file mode 100644 index 00000000..59a5b86d --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/NormalizerTest.kt @@ -0,0 +1,258 @@ +package test + +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.Operation +import com.apollographql.apollo.api.toApolloResponse +import com.apollographql.cache.normalized.api.CacheHeaders +import com.apollographql.cache.normalized.api.CacheKey +import com.apollographql.cache.normalized.api.DefaultRecordMerger +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.api.NormalizedCache +import com.apollographql.cache.normalized.api.Record +import com.apollographql.cache.normalized.api.normalize +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import httpcache.AllPlanetsQuery +import normalizer.EpisodeHeroNameQuery +import normalizer.HeroAndFriendsNamesQuery +import normalizer.HeroAndFriendsNamesWithIDForParentOnlyQuery +import normalizer.HeroAndFriendsNamesWithIDsQuery +import normalizer.HeroAppearsInQuery +import normalizer.HeroNameQuery +import normalizer.HeroParentTypeDependentFieldQuery +import normalizer.HeroTypeDependentAliasedFieldQuery +import normalizer.SameHeroTwiceQuery +import normalizer.type.Episode +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Tests for the normalization without an instance of [com.apollographql.apollo.ApolloClient] + */ +class NormalizerTest { + private lateinit var normalizedCache: NormalizedCache + + private val rootKey = "QUERY_ROOT" + + @BeforeTest + fun setUp() { + normalizedCache = MemoryCacheFactory().create() + } + + @Test + @Throws(Exception::class) + fun testHeroName() { + val records = records(HeroNameQuery(), "HeroNameResponse.json") + val record = records.get(rootKey) + val reference = record!!["hero"] as CacheKey? + assertEquals(reference, CacheKey("hero")) + val heroRecord = records.get(reference!!.key) + assertEquals(heroRecord!!["name"], "R2-D2") + } + + @Test + @Throws(Exception::class) + fun testMergeNull() { + val record = Record( + key = "Key", + fields = mapOf("field1" to "value1"), + ) + normalizedCache.merge(listOf(record), CacheHeaders.NONE, DefaultRecordMerger) + + val newRecord = Record( + key = "Key", + fields = mapOf("field2" to null), + ) + + normalizedCache.merge(listOf(newRecord), CacheHeaders.NONE, DefaultRecordMerger) + val finalRecord = normalizedCache.loadRecord(record.key, CacheHeaders.NONE) + assertTrue(finalRecord!!.containsKey("field2")) + normalizedCache.remove(CacheKey(record.key), false) + } + + @Test + @Throws(Exception::class) + fun testHeroNameWithVariable() { + val records = records(EpisodeHeroNameQuery(Episode.JEDI), "EpisodeHeroNameResponse.json") + val record = records.get(rootKey) + val reference = record!![TEST_FIELD_KEY_JEDI] as CacheKey? + assertEquals(reference, CacheKey(TEST_FIELD_KEY_JEDI)) + val heroRecord = records.get(reference!!.key) + assertEquals(heroRecord!!["name"], "R2-D2") + } + + @Test + @Throws(Exception::class) + fun testHeroAppearsInQuery() { + val records = records(HeroAppearsInQuery(), "HeroAppearsInResponse.json") + + val rootRecord = records.get(rootKey)!! + + val heroReference = rootRecord["hero"] as CacheKey? + assertEquals(heroReference, CacheKey("hero")) + + val hero = records.get(heroReference!!.key) + assertEquals(hero?.get("appearsIn"), listOf("NEWHOPE", "EMPIRE", "JEDI")) + } + + @Test + @Throws(Exception::class) + fun testHeroAndFriendsNamesQueryWithoutIDs() { + val records = records(HeroAndFriendsNamesQuery(Episode.JEDI), "HeroAndFriendsNameResponse.json") + val record = records.get(rootKey) + val heroReference = record!![TEST_FIELD_KEY_JEDI] as CacheKey? + assertEquals(heroReference, CacheKey(TEST_FIELD_KEY_JEDI)) + val heroRecord = records.get(heroReference!!.key) + assertEquals(heroRecord!!["name"], "R2-D2") + assertEquals( + listOf( + CacheKey("$TEST_FIELD_KEY_JEDI.friends.0"), + CacheKey("$TEST_FIELD_KEY_JEDI.friends.1"), + CacheKey("$TEST_FIELD_KEY_JEDI.friends.2") + ), + heroRecord["friends"] + ) + val luke = records.get("$TEST_FIELD_KEY_JEDI.friends.0") + assertEquals(luke!!["name"], "Luke Skywalker") + } + + @Test + @Throws(Exception::class) + fun testHeroAndFriendsNamesQueryWithIDs() { + val records = records(HeroAndFriendsNamesWithIDsQuery(Episode.JEDI), "HeroAndFriendsNameWithIdsResponse.json") + val record = records.get(rootKey) + val heroReference = record!![TEST_FIELD_KEY_JEDI] as CacheKey? + assertEquals(CacheKey("Character:2001"), heroReference) + val heroRecord = records.get(heroReference!!.key) + assertEquals(heroRecord!!["name"], "R2-D2") + assertEquals( + listOf( + CacheKey("Character:1000"), + CacheKey("Character:1002"), + CacheKey("Character:1003") + ), + heroRecord["friends"] + ) + val luke = records.get("Character:1000") + assertEquals(luke!!["name"], "Luke Skywalker") + } + + @Test + @Throws(Exception::class) + fun testHeroAndFriendsNamesWithIDForParentOnly() { + val records = records(HeroAndFriendsNamesWithIDForParentOnlyQuery(Episode.JEDI), "HeroAndFriendsNameWithIdsParentOnlyResponse.json") + val record = records[rootKey] + val heroReference = record!![TEST_FIELD_KEY_JEDI] as CacheKey? + assertEquals(CacheKey("Character:2001"), heroReference) + val heroRecord = records.get(heroReference!!.key) + assertEquals(heroRecord!!["name"], "R2-D2") + assertEquals( + listOf( + CacheKey("Character:2001.friends.0"), + CacheKey("Character:2001.friends.1"), + CacheKey("Character:2001.friends.2") + ), + heroRecord["friends"] + ) + val luke = records.get("Character:2001.friends.0") + assertEquals(luke!!["name"], "Luke Skywalker") + } + + @Test + @Throws(Exception::class) + fun testSameHeroTwiceQuery() { + val records = records(SameHeroTwiceQuery(), "SameHeroTwiceResponse.json") + val record = records.get(rootKey) + val heroReference = record!!["hero"] as CacheKey? + val hero = records.get(heroReference!!.key) + + assertEquals(hero!!["name"], "R2-D2") + assertEquals(hero["appearsIn"], listOf("NEWHOPE", "EMPIRE", "JEDI")) + } + + @Test + @Throws(Exception::class) + fun testHeroTypeDependentAliasedFieldQueryDroid() { + val records = records(HeroTypeDependentAliasedFieldQuery(Episode.JEDI), "HeroTypeDependentAliasedFieldResponse.json") + val record = records.get(rootKey) + val heroReference = record!![TEST_FIELD_KEY_JEDI] as CacheKey? + val hero = records.get(heroReference!!.key) + assertEquals(hero!!["primaryFunction"], "Astromech") + assertEquals(hero["__typename"], "Droid") + } + + @Test + @Throws(Exception::class) + fun testHeroTypeDependentAliasedFieldQueryHuman() { + val records = records(HeroTypeDependentAliasedFieldQuery(Episode.EMPIRE), "HeroTypeDependentAliasedFieldResponseHuman.json") + val record = records.get(rootKey) + val heroReference = record!![TEST_FIELD_KEY_EMPIRE] as CacheKey? + val hero = records.get(heroReference!!.key) + assertEquals(hero!!["homePlanet"], "Tatooine") + assertEquals(hero["__typename"], "Human") + } + + @Test + @Throws(Exception::class) + fun testHeroParentTypeDependentAliasedFieldQueryHuman() { + val records = records(HeroTypeDependentAliasedFieldQuery(Episode.EMPIRE), "HeroTypeDependentAliasedFieldResponseHuman.json") + val record = records.get(rootKey) + val heroReference = record!![TEST_FIELD_KEY_EMPIRE] as CacheKey? + val hero = records.get(heroReference!!.key) + assertEquals(hero!!["homePlanet"], "Tatooine") + assertEquals(hero["__typename"], "Human") + } + + @Test + @Throws(Exception::class) + fun testHeroParentTypeDependentFieldDroid() { + val records = records(HeroParentTypeDependentFieldQuery(Episode.JEDI), "HeroParentTypeDependentFieldDroidResponse.json") + val lukeRecord = records.get(TEST_FIELD_KEY_JEDI + ".friends.0") + assertEquals(lukeRecord!!["name"], "Luke Skywalker") + assertEquals(lukeRecord["height({\"unit\":\"METER\"})"], 1.72) + + + val friends = records[TEST_FIELD_KEY_JEDI]!!["friends"] + + assertIs>(friends) + assertEquals(friends[0], CacheKey("$TEST_FIELD_KEY_JEDI.friends.0")) + assertEquals(friends[1], CacheKey("$TEST_FIELD_KEY_JEDI.friends.1")) + assertEquals(friends[2], CacheKey("$TEST_FIELD_KEY_JEDI.friends.2")) + } + + @Test + fun list_of_objects_with_null_object() { + val records = records(AllPlanetsQuery(), "AllPlanetsListOfObjectWithNullObject.json") + val fieldKey = "allPlanets({\"first\":300})" + + var record: Record? = records["$fieldKey.planets.0"] + assertTrue(record?.get("filmConnection") == null) + record = records.get("$fieldKey.planets.0.filmConnection") + assertTrue(record == null) + record = records.get("$fieldKey.planets.1.filmConnection") + assertTrue(record != null) + } + + + @Test + @Throws(Exception::class) + fun testHeroParentTypeDependentFieldHuman() { + val records = records(HeroParentTypeDependentFieldQuery(Episode.EMPIRE), "HeroParentTypeDependentFieldHumanResponse.json") + + val lukeRecord = records.get("$TEST_FIELD_KEY_EMPIRE.friends.0") + assertEquals(lukeRecord!!["name"], "Han Solo") + assertEquals(lukeRecord["height({\"unit\":\"FOOT\"})"], 5.905512) + } + + companion object { + internal fun records(operation: Operation, name: String): Map { + val response = testFixtureToJsonReader(name).toApolloResponse(operation) + return operation.normalize(data = response.data!!, CustomScalarAdapters.Empty, cacheKeyGenerator = IdCacheKeyGenerator()) + } + + private const val TEST_FIELD_KEY_JEDI = "hero({\"episode\":\"JEDI\"})" + const val TEST_FIELD_KEY_EMPIRE = "hero({\"episode\":\"EMPIRE\"})" + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/OptimisticCacheTest.kt b/tests/normalized-cache/src/commonTest/kotlin/OptimisticCacheTest.kt new file mode 100644 index 00000000..923dd836 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/OptimisticCacheTest.kt @@ -0,0 +1,403 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.Optional +import com.apollographql.apollo.testing.awaitElement +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.optimisticUpdates +import com.apollographql.cache.normalized.refetchPolicy +import com.apollographql.cache.normalized.store +import com.apollographql.cache.normalized.watch +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import com.benasher44.uuid.uuid4 +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import normalizer.HeroAndFriendsNamesQuery +import normalizer.HeroAndFriendsNamesWithIDsQuery +import normalizer.HeroNameWithIdQuery +import normalizer.ReviewsByEpisodeQuery +import normalizer.UpdateReviewMutation +import normalizer.type.ColorInput +import normalizer.type.Episode +import normalizer.type.ReviewInput +import kotlin.test.Test +import kotlin.test.assertEquals + +class OptimisticCacheTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore(MemoryCacheFactory(), cacheKeyGenerator = IdCacheKeyGenerator()) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + /** + * Write the updates programmatically, make sure they are seen, + * roll them back, make sure we're back to the initial state + */ + @Test + fun programmaticOptimiticUpdates() = runTest(before = { setUp() }, after = { tearDown() }) { + val query = HeroAndFriendsNamesQuery(Episode.JEDI) + + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNameResponse.json")) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + val mutationId = uuid4() + val data = HeroAndFriendsNamesQuery.Data(HeroAndFriendsNamesQuery.Hero( + "R222-D222", + listOf( + HeroAndFriendsNamesQuery.Friend( + "SuperMan" + ), + HeroAndFriendsNamesQuery.Friend( + "Batman" + ) + ) + ) + ) + store.writeOptimisticUpdates( + operation = query, + operationData = data, + mutationId = mutationId, + ).also { + store.publish(it) + } + + var response = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute() + + assertEquals(response.data?.hero?.name, "R222-D222") + assertEquals(response.data?.hero?.friends?.size, 2) + assertEquals(response.data?.hero?.friends?.get(0)?.name, "SuperMan") + assertEquals(response.data?.hero?.friends?.get(1)?.name, "Batman") + + store.rollbackOptimisticUpdates(mutationId) + response = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute() + + assertEquals(response.data?.hero?.name, "R2-D2") + assertEquals(response.data?.hero?.friends?.size, 3) + assertEquals(response.data?.hero?.friends?.get(0)?.name, "Luke Skywalker") + assertEquals(response.data?.hero?.friends?.get(1)?.name, "Han Solo") + assertEquals(response.data?.hero?.friends?.get(2)?.name, "Leia Organa") + } + + /** + * A more complex scenario where we stack optimistic updates + */ + @Test + fun two_optimistic_two_rollback() = runTest(before = { setUp() }, after = { tearDown() }) { + val query1 = HeroAndFriendsNamesWithIDsQuery(Episode.JEDI) + val mutationId1 = uuid4() + + // execute query1 from the network + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNameWithIdsResponse.json")) + apolloClient.query(query1).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // now write some optimistic updates for query1 + val data1 = HeroAndFriendsNamesWithIDsQuery.Data( + HeroAndFriendsNamesWithIDsQuery.Hero( + "2001", + "R222-D222", + listOf( + HeroAndFriendsNamesWithIDsQuery.Friend( + "1000", + "SuperMan" + ), + HeroAndFriendsNamesWithIDsQuery.Friend( + "1003", + "Batman" + ) + ) + ) + ) + store.writeOptimisticUpdates( + operation = query1, + operationData = data1, + mutationId = mutationId1, + ).also { + store.publish(it) + } + + // check if query1 see optimistic updates + var response1 = apolloClient.query(query1).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response1.data?.hero?.id, "2001") + assertEquals(response1.data?.hero?.name, "R222-D222") + assertEquals(response1.data?.hero?.friends?.size, 2) + assertEquals(response1.data?.hero?.friends?.get(0)?.id, "1000") + assertEquals(response1.data?.hero?.friends?.get(0)?.name, "SuperMan") + assertEquals(response1.data?.hero?.friends?.get(1)?.id, "1003") + assertEquals(response1.data?.hero?.friends?.get(1)?.name, "Batman") + + // execute query2 + val query2 = HeroNameWithIdQuery() + val mutationId2 = uuid4() + + mockServer.enqueueString(testFixtureToUtf8("HeroNameWithIdResponse.json")) + apolloClient.query(query2).execute() + + // write optimistic data2 + val data2 = HeroNameWithIdQuery.Data(HeroNameWithIdQuery.Hero( + "1000", + "Beast" + ) + ) + store.writeOptimisticUpdates( + operation = query2, + operationData = data2, + mutationId = mutationId2, + ).also { + store.publish(it) + } + + // check if query1 sees data2 + response1 = apolloClient.query(query1).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response1.data?.hero?.id, "2001") + assertEquals(response1.data?.hero?.name, "R222-D222") + assertEquals(response1.data?.hero?.friends?.size, 2) + assertEquals(response1.data?.hero?.friends?.get(0)?.id, "1000") + assertEquals(response1.data?.hero?.friends?.get(0)?.name, "Beast") + assertEquals(response1.data?.hero?.friends?.get(1)?.id, "1003") + assertEquals(response1.data?.hero?.friends?.get(1)?.name, "Batman") + + // check if query2 sees data2 + var response2 = apolloClient.query(query2).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response2.data?.hero?.id, "1000") + assertEquals(response2.data?.hero?.name, "Beast") + + // rollback data1 + store.rollbackOptimisticUpdates(mutationId1) + + // check if query2 sees the rollback + response1 = apolloClient.query(query1).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response1.data?.hero?.id, "2001") + assertEquals(response1.data?.hero?.name, "R2-D2") + assertEquals(response1.data?.hero?.friends?.size, 3) + assertEquals(response1.data?.hero?.friends?.get(0)?.id, "1000") + assertEquals(response1.data?.hero?.friends?.get(0)?.name, "Beast") + assertEquals(response1.data?.hero?.friends?.get(1)?.id, "1002") + assertEquals(response1.data?.hero?.friends?.get(1)?.name, "Han Solo") + assertEquals(response1.data?.hero?.friends?.get(2)?.id, "1003") + assertEquals(response1.data?.hero?.friends?.get(2)?.name, "Leia Organa") + + // check if query2 see the latest optimistic updates + response2 = apolloClient.query(query2).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response2.data?.hero?.id, "1000") + assertEquals(response2.data?.hero?.name, "Beast") + + // rollback query2 optimistic updates + store.rollbackOptimisticUpdates(mutationId2) + + // check if query2 see the latest optimistic updates + response2 = apolloClient.query(query2).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response2.data?.hero?.id, "1000") + assertEquals(response2.data?.hero?.name, "SuperMan") + } + + @Test + fun mutation_and_query_watcher() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("ReviewsEmpireEpisodeResponse.json")) + val channel = Channel() + val job = launch { + apolloClient.query(ReviewsByEpisodeQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .refetchPolicy(FetchPolicy.CacheOnly) + .watch() + .collect { + channel.send(it.data) + } + } + + var watcherData = channel.receive() + + // before mutation and optimistic updates + assertEquals(watcherData?.reviews?.size, 3) + assertEquals(watcherData?.reviews?.get(0)?.id, "empireReview1") + assertEquals(watcherData?.reviews?.get(0)?.stars, 1) + assertEquals(watcherData?.reviews?.get(0)?.commentary, "Boring") + assertEquals(watcherData?.reviews?.get(1)?.id, "empireReview2") + assertEquals(watcherData?.reviews?.get(1)?.stars, 2) + assertEquals(watcherData?.reviews?.get(1)?.commentary, "So-so") + assertEquals(watcherData?.reviews?.get(2)?.id, "empireReview3") + assertEquals(watcherData?.reviews?.get(2)?.stars, 5) + assertEquals(watcherData?.reviews?.get(2)?.commentary, "Amazing") + + /** + * There is a small potential for a race condition here. The changedKeys event from the optimistic updates might + * be received after the network response has been written and therefore the refetch will see the new data right ahead. + * + * To limit the occurence of this happening, we introduce a small delay in the network response here. + */ + mockServer.enqueueString(testFixtureToUtf8("UpdateReviewResponse.json"), 100) + val updateReviewMutation = UpdateReviewMutation( + "empireReview2", + ReviewInput( + stars = 4, + commentary = Optional.Present("Not Bad"), + favoriteColor = ColorInput( + red = Optional.Absent, + green = Optional.Absent, + blue = Optional.Absent + ) + ) + ) + apolloClient.mutation(updateReviewMutation).optimisticUpdates( + UpdateReviewMutation.Data( + UpdateReviewMutation.UpdateReview( + "empireReview2", + 5, + "Great" + ) + ) + ).execute() + + /** + * optimistic updates + */ + watcherData = channel.receive() + assertEquals(watcherData?.reviews?.size, 3) + assertEquals(watcherData?.reviews?.get(0)?.id, "empireReview1") + assertEquals(watcherData?.reviews?.get(0)?.stars, 1) + assertEquals(watcherData?.reviews?.get(0)?.commentary, "Boring") + assertEquals(watcherData?.reviews?.get(1)?.id, "empireReview2") + assertEquals(watcherData?.reviews?.get(1)?.stars, 5) + assertEquals(watcherData?.reviews?.get(1)?.commentary, "Great") + assertEquals(watcherData?.reviews?.get(2)?.id, "empireReview3") + assertEquals(watcherData?.reviews?.get(2)?.stars, 5) + assertEquals(watcherData?.reviews?.get(2)?.commentary, "Amazing") + + // after mutation with rolled back optimistic updates + @Suppress("DEPRECATION") + watcherData = channel.awaitElement() + assertEquals(watcherData?.reviews?.size, 3) + assertEquals(watcherData?.reviews?.get(0)?.id, "empireReview1") + assertEquals(watcherData?.reviews?.get(0)?.stars, 1) + assertEquals(watcherData?.reviews?.get(0)?.commentary, "Boring") + assertEquals(watcherData?.reviews?.get(1)?.id, "empireReview2") + assertEquals(watcherData?.reviews?.get(1)?.stars, 4) + assertEquals(watcherData?.reviews?.get(1)?.commentary, "Not Bad") + assertEquals(watcherData?.reviews?.get(2)?.id, "empireReview3") + assertEquals(watcherData?.reviews?.get(2)?.stars, 5) + assertEquals(watcherData?.reviews?.get(2)?.commentary, "Amazing") + + job.cancel() + } + + @Test + @Throws(Exception::class) + fun two_optimistic_reverse_rollback_order() = runTest(before = { setUp() }, after = { tearDown() }) { + val query1 = HeroAndFriendsNamesWithIDsQuery(Episode.JEDI) + val mutationId1 = uuid4() + val query2 = HeroNameWithIdQuery() + val mutationId2 = uuid4() + + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNameWithIdsResponse.json")) + apolloClient.query(query1).execute() + + mockServer.enqueueString(testFixtureToUtf8("HeroNameWithIdResponse.json")) + apolloClient.query(query2).execute() + + val data1 = HeroAndFriendsNamesWithIDsQuery.Data( + HeroAndFriendsNamesWithIDsQuery.Hero( + "2001", + "R222-D222", + listOf( + HeroAndFriendsNamesWithIDsQuery.Friend( + "1000", + "Robocop" + ), + HeroAndFriendsNamesWithIDsQuery.Friend( + "1003", + "Batman" + ) + ) + ) + ) + store.writeOptimisticUpdates( + operation = query1, + operationData = data1, + mutationId = mutationId1, + ).also { + store.publish(it) + } + val data2 = HeroNameWithIdQuery.Data(HeroNameWithIdQuery.Hero( + "1000", + "Spiderman" + ) + ) + store.writeOptimisticUpdates( + operation = query2, + operationData = data2, + mutationId = mutationId2, + ).also { + store.publish(it) + } + + // check if query1 see optimistic updates + var response1 = apolloClient.query(query1).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response1.data?.hero?.id, "2001") + assertEquals(response1.data?.hero?.name, "R222-D222") + assertEquals(response1.data?.hero?.friends?.size, 2) + assertEquals(response1.data?.hero?.friends?.get(0)?.id, "1000") + assertEquals(response1.data?.hero?.friends?.get(0)?.name, "Spiderman") + assertEquals(response1.data?.hero?.friends?.get(1)?.id, "1003") + assertEquals(response1.data?.hero?.friends?.get(1)?.name, "Batman") + + + // check if query2 see the latest optimistic updates + var response2 = apolloClient.query(query2).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response2.data?.hero?.id, "1000") + assertEquals(response2.data?.hero?.name, "Spiderman") + + // rollback query2 optimistic updates + store.rollbackOptimisticUpdates(mutationId2) + + // check if query1 see the latest optimistic updates + response1 = apolloClient.query(query1).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response1.data?.hero?.id, "2001") + assertEquals(response1.data?.hero?.name, "R222-D222") + assertEquals(response1.data?.hero?.friends?.size, 2) + assertEquals(response1.data?.hero?.friends?.get(0)?.id, "1000") + assertEquals(response1.data?.hero?.friends?.get(0)?.name, "Robocop") + assertEquals(response1.data?.hero?.friends?.get(1)?.id, "1003") + assertEquals(response1.data?.hero?.friends?.get(1)?.name, "Batman") + + + // check if query2 see the latest optimistic updates + response2 = apolloClient.query(query2).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response2.data?.hero?.id, "1000") + assertEquals(response2.data?.hero?.name, "Robocop") + + // rollback query1 optimistic updates + store.rollbackOptimisticUpdates(mutationId1) + + // check if query1 see the latest non-optimistic updates + response1 = apolloClient.query(query1).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response1.data?.hero?.id, "2001") + assertEquals(response1.data?.hero?.name, "R2-D2") + assertEquals(response1.data?.hero?.friends?.size, 3) + assertEquals(response1.data?.hero?.friends?.get(0)?.id, "1000") + assertEquals(response1.data?.hero?.friends?.get(0)?.name, "SuperMan") + assertEquals(response1.data?.hero?.friends?.get(1)?.id, "1002") + assertEquals(response1.data?.hero?.friends?.get(1)?.name, "Han Solo") + assertEquals(response1.data?.hero?.friends?.get(2)?.id, "1003") + assertEquals(response1.data?.hero?.friends?.get(2)?.name, "Leia Organa") + + + // check if query2 see the latest non-optimistic updates + response2 = apolloClient.query(query2).fetchPolicy(FetchPolicy.CacheOnly).execute() + assertEquals(response2.data?.hero?.id, "1000") + assertEquals(response2.data?.hero?.name, "SuperMan") + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/OtherCacheTest.kt b/tests/normalized-cache/src/commonTest/kotlin/OtherCacheTest.kt new file mode 100644 index 00000000..bbc92fc2 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/OtherCacheTest.kt @@ -0,0 +1,190 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.composeJsonResponse +import com.apollographql.apollo.exception.CacheMissException +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.api.IdCacheKeyResolver +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import normalizer.CharacterDetailsQuery +import normalizer.CharacterNameByIdQuery +import normalizer.EpisodeHeroNameQuery +import normalizer.HeroAndFriendsDirectivesQuery +import normalizer.HeroAndFriendsNamesWithIDsQuery +import normalizer.InstantQuery +import normalizer.UpdateReviewWithoutVariableMutation +import normalizer.type.Episode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Every other test that doesn't fit in the other files + */ +class OtherCacheTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore(MemoryCacheFactory(), cacheKeyGenerator = IdCacheKeyGenerator(), cacheResolver = IdCacheKeyResolver()) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private suspend fun tearDown() { + mockServer.close() + } + + @Test + fun masterDetailSuccess() = runTest(before = { setUp() }, after = { tearDown() }) { + // Store a query that contains all data + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNameWithIdsResponse.json")) + apolloClient.query(HeroAndFriendsNamesWithIDsQuery(Episode.NEWHOPE)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + + // Getting a subtree of that data should work + val detailsResponse = apolloClient.query(CharacterNameByIdQuery("1002")) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + + + assertEquals(detailsResponse.data?.character!!.name, "Han Solo") + } + + @Test + @Throws(Exception::class) + fun masterDetailFailIncomplete() = runTest(before = { setUp() }, after = { tearDown() }) { + // Store a query that contains all data + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNameWithIdsResponse.json")) + apolloClient.query(HeroAndFriendsNamesWithIDsQuery(Episode.NEWHOPE)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + + // Some details are not present in the master query, we should get a cache miss + val e = apolloClient.query(CharacterDetailsQuery("1002")).fetchPolicy(FetchPolicy.CacheOnly).execute().exception as CacheMissException + assertTrue(e.message!!.contains("Object 'Character:1002' has no field named '__typename'")) + } + + + @Test + fun cacheMissThrows() = runTest(before = { setUp() }, after = { tearDown() }) { + val e = apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + .exception!! + assertTrue(e.message!!.contains("Object 'QUERY_ROOT' has no field named 'hero")) + } + + @Test + @Throws(Exception::class) + fun skipIncludeDirective() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNameResponse.json")) + apolloClient.query(HeroAndFriendsDirectivesQuery(Episode.JEDI, true, false)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + + var response = apolloClient.query( + HeroAndFriendsDirectivesQuery(Episode.JEDI, true, false) + ) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + + assertEquals("R2-D2", response.data?.hero?.name) + assertEquals(3, response.data?.hero?.friends?.size) + assertEquals("Luke Skywalker", response.data?.hero?.friends?.get(0)?.name) + assertEquals("Han Solo", response.data?.hero?.friends?.get(1)?.name) + assertEquals("Leia Organa", response.data?.hero?.friends?.get(2)?.name) + + response = apolloClient.query( + HeroAndFriendsDirectivesQuery(Episode.JEDI, false, false) + ) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + + assertNull(response.data?.hero?.name) + assertEquals(3, response.data?.hero?.friends?.size) + assertEquals("Luke Skywalker", response.data?.hero?.friends?.get(0)?.name) + assertEquals("Han Solo", response.data?.hero?.friends?.get(1)?.name) + assertEquals("Leia Organa", response.data?.hero?.friends?.get(2)?.name) + + response = apolloClient.query( + HeroAndFriendsDirectivesQuery(Episode.JEDI, true, true) + ) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + + assertEquals("R2-D2", response.data?.hero?.name) + assertNull(response.data?.hero?.friends) + } + + + @Test + fun skipIncludeDirectiveUnsatisfiedCache() = runTest(before = { setUp() }, after = { tearDown() }) { + // Store a response that doesn't contain friends + mockServer.enqueueString(testFixtureToUtf8("HeroNameResponse.json")) + apolloClient.query(HeroAndFriendsDirectivesQuery(Episode.JEDI, true, true)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + + + // Get it from the cache, we should get the name but no friends + val response = apolloClient.query(HeroAndFriendsDirectivesQuery(Episode.JEDI, true, true)) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + + assertEquals(response.data?.hero?.name, "R2-D2") + assertEquals(response.data?.hero?.friends, null) + + // Now try to get the friends from the cache, it should fail + val e = apolloClient.query(HeroAndFriendsDirectivesQuery(Episode.JEDI, true, false)) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + .exception as CacheMissException + + assertTrue(e.message!!.contains("has no field named 'friends'")) + } + + @Test + fun withCompileTimeScalarAdapter() = runTest(before = { setUp() }, after = { tearDown() }) { + val query = InstantQuery() + // Store in the cache + val instant = "now" + val data = InstantQuery.Data(instant) + mockServer.enqueueString(query.composeJsonResponse(data)) + apolloClient.query(query).execute() + + // Get from the cache + val response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + + assertEquals(instant, response.data!!.instant) + } + + @Test + fun cacheFieldWithObjectValueArgument() = runTest(before = { setUp() }, after = { tearDown() }) { + val mutation = UpdateReviewWithoutVariableMutation() + val data = UpdateReviewWithoutVariableMutation.Data( + UpdateReviewWithoutVariableMutation.UpdateReview( + "0", + 5, + "Great" + ) + ) + mockServer.enqueueString(mutation.composeJsonResponse(data)) + apolloClient.mutation(mutation).execute() + + val storeData = store.readOperation(mutation).data + assertEquals(data, storeData) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/StoreTest.kt b/tests/normalized-cache/src/commonTest/kotlin/StoreTest.kt new file mode 100644 index 00000000..5ac9b9d9 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/StoreTest.kt @@ -0,0 +1,192 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.exception.CacheMissException +import com.apollographql.apollo.testing.QueueTestNetworkTransport +import com.apollographql.apollo.testing.enqueueTestResponse +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.CacheKey +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.api.IdCacheKeyResolver +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.isFromCache +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import normalizer.CharacterNameByIdQuery +import normalizer.HeroAndFriendsNamesWithIDsQuery +import normalizer.type.Episode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * Tests that write into the store programmatically. + * + * XXX: Do we need a client and mockServer for these tests? + */ +class StoreTest { + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private fun setUp() { + store = ApolloStore(MemoryCacheFactory(), cacheKeyGenerator = IdCacheKeyGenerator(), cacheResolver = IdCacheKeyResolver()) + apolloClient = ApolloClient.Builder().networkTransport(QueueTestNetworkTransport()).store(store).build() + } + + @Test + fun removeFromStore() = runTest(before = { setUp() }) { + storeAllFriends() + assertFriendIsCached("1002", "Han Solo") + + // remove the root query object + var removed = store.remove(CacheKey("Character:2001")) + assertEquals(true, removed) + + // Trying to get the full response should fail + assertRootNotCached() + + // put everything in the cache + storeAllFriends() + assertFriendIsCached("1002", "Han Solo") + + // remove a single object from the list + removed = store.remove(CacheKey("Character:1002")) + assertEquals(true, removed) + + // Trying to get the full response should fail + assertRootNotCached() + + // Trying to get the object we just removed should fail + assertFriendIsNotCached("1002") + + // Trying to get another object we did not remove should work + assertFriendIsCached("1003", "Leia Organa") + } + + @Test + @Throws(Exception::class) + fun removeMultipleFromStore() = runTest(before = { setUp() }) { + storeAllFriends() + assertFriendIsCached("1000", "Luke Skywalker") + assertFriendIsCached("1002", "Han Solo") + assertFriendIsCached("1003", "Leia Organa") + + // Now remove multiple keys + val removed = store.remove(listOf(CacheKey("Character:1002"), CacheKey("Character:1000"))) + + assertEquals(2, removed) + + // Trying to get the objects we just removed should fail + assertFriendIsNotCached("1000") + assertFriendIsNotCached("1002") + assertFriendIsCached("1003", "Leia Organa") + } + + @Test + @Throws(Exception::class) + fun cascadeRemove() = runTest(before = { setUp() }) { + // put everything in the cache + storeAllFriends() + + assertFriendIsCached("1000", "Luke Skywalker") + assertFriendIsCached("1002", "Han Solo") + assertFriendIsCached("1003", "Leia Organa") + + // test remove root query object + val removed = store.remove(CacheKey("Character:2001"), true) + assertEquals(true, removed) + + // Nothing should be cached anymore + assertRootNotCached() + assertFriendIsNotCached("1000") + assertFriendIsNotCached("1002") + assertFriendIsNotCached("1003") + } + + @Test + @Throws(Exception::class) + fun directAccess() = runTest(before = { setUp() }) { + // put everything in the cache + storeAllFriends() + + store.accessCache { + it.remove("Character:10%") + } + assertFriendIsNotCached("1000") + assertFriendIsNotCached("1002") + assertFriendIsNotCached("1003") + } + + @Test + fun testNewBuilderNewStore() = runTest(before = { setUp() }) { + storeAllFriends() + assertFriendIsCached("1000", "Luke Skywalker") + + val newStore = ApolloStore(MemoryCacheFactory()) + val newClient = apolloClient.newBuilder().store(newStore).build() + + assertFriendIsNotCached("1000", newClient) + } + + private suspend fun storeAllFriends() { + val query = HeroAndFriendsNamesWithIDsQuery(Episode.NEWHOPE) + apolloClient.enqueueTestResponse(query, HeroAndFriendsNamesWithIDsQuery.Data( + HeroAndFriendsNamesWithIDsQuery.Hero( + "2001", + "R2-D2", + listOf( + HeroAndFriendsNamesWithIDsQuery.Friend( + "1000", + "Luke Skywalker" + ), + HeroAndFriendsNamesWithIDsQuery.Friend( + "1002", + "Han Solo" + ), + HeroAndFriendsNamesWithIDsQuery.Friend( + "1003", + "Leia Organa" + ), + ) + ) + ) + ) + val response = apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkOnly).execute() + + assertEquals(response.data?.hero?.name, "R2-D2") + assertEquals(response.data?.hero?.friends?.size, 3) + } + + private suspend fun assertFriendIsCached(id: String, name: String) { + val characterResponse = apolloClient.query(CharacterNameByIdQuery(id)) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + + assertEquals(true, characterResponse.isFromCache) + assertEquals(name, characterResponse.data?.character?.name) + } + + private suspend fun assertFriendIsNotCached( + id: String, + apolloClientToUse: ApolloClient = apolloClient, + ) { + assertIs( + apolloClientToUse.query(CharacterNameByIdQuery(id)) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + .exception + ) + } + + private suspend fun assertRootNotCached() { + assertIs( + apolloClient.query(HeroAndFriendsNamesWithIDsQuery(Episode.NEWHOPE)) + .fetchPolicy(FetchPolicy.CacheOnly) + .execute() + .exception + ) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/ThreadTests.kt b/tests/normalized-cache/src/commonTest/kotlin/ThreadTests.kt new file mode 100644 index 00000000..e5401962 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/ThreadTests.kt @@ -0,0 +1,113 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.Platform +import com.apollographql.apollo.testing.QueueTestNetworkTransport +import com.apollographql.apollo.testing.currentThreadId +import com.apollographql.apollo.testing.enqueueTestResponse +import com.apollographql.apollo.testing.platform +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.CacheHeaders +import com.apollographql.cache.normalized.api.CacheKey +import com.apollographql.cache.normalized.api.NormalizedCache +import com.apollographql.cache.normalized.api.NormalizedCacheFactory +import com.apollographql.cache.normalized.api.Record +import com.apollographql.cache.normalized.api.RecordMerger +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCache +import com.apollographql.cache.normalized.normalizedCache +import kotlinx.coroutines.test.runTest +import normalizer.HeroNameQuery +import kotlin.reflect.KClass +import kotlin.test.Test + +class ThreadTests { + @Suppress("DEPRECATION") + class MyNormalizedCache(private val mainThreadId: String) : NormalizedCache { + val delegate = MemoryCache() + override fun merge(record: Record, cacheHeaders: CacheHeaders, recordMerger: RecordMerger): Set { + check(currentThreadId() != mainThreadId) { + "Cache access on main thread" + } + return delegate.merge(record, cacheHeaders, recordMerger) + } + + override fun merge(records: Collection, cacheHeaders: CacheHeaders, recordMerger: RecordMerger): Set { + check(currentThreadId() != mainThreadId) { + "Cache access on main thread" + } + return delegate.merge(records, cacheHeaders, recordMerger) + } + + override fun clearAll() { + check(currentThreadId() != mainThreadId) { + "Cache access on main thread" + } + return delegate.clearAll() + } + + override fun remove(cacheKey: CacheKey, cascade: Boolean): Boolean { + check(currentThreadId() != mainThreadId) { + "Cache access on main thread" + } + return delegate.remove(cacheKey, cascade) + } + + override fun remove(pattern: String): Int { + check(currentThreadId() != mainThreadId) { + "Cache access on main thread" + } + return delegate.remove(pattern) + } + + override fun loadRecord(key: String, cacheHeaders: CacheHeaders): Record? { + check(currentThreadId() != mainThreadId) { + "Cache access on main thread" + } + return delegate.loadRecord(key, cacheHeaders) + } + + override fun loadRecords(keys: Collection, cacheHeaders: CacheHeaders): Collection { + check(currentThreadId() != mainThreadId) { + "Cache access on main thread" + } + return delegate.loadRecords(keys, cacheHeaders) + } + + override fun dump(): Map, Map> { + check(currentThreadId() != mainThreadId) { + "Cache access on main thread" + } + return delegate.dump() + } + } + + class MyMemoryCacheFactory(val mainThreadId: String) : NormalizedCacheFactory() { + override fun create(): NormalizedCache { + return MyNormalizedCache(mainThreadId) + } + + } + + @Test + fun cacheIsNotReadFromTheMainThread() = runTest { + @Suppress("DEPRECATION") + if (platform() == Platform.Js) { + return@runTest + } + + @Suppress("DEPRECATION") + val apolloClient = ApolloClient.Builder() + .normalizedCache(MyMemoryCacheFactory(currentThreadId())) + .networkTransport(QueueTestNetworkTransport()) + .build() + + val data = HeroNameQuery.Data(HeroNameQuery.Hero("Luke")) + val query = HeroNameQuery() + apolloClient.enqueueTestResponse(query, data) + + apolloClient.query(query).execute() + apolloClient.query(query).fetchPolicy(FetchPolicy.CacheOnly).execute() + apolloClient.close() + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/Utils.kt b/tests/normalized-cache/src/commonTest/kotlin/Utils.kt new file mode 100644 index 00000000..70660a3d --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/Utils.kt @@ -0,0 +1,10 @@ +package test + +import com.apollographql.apollo.testing.pathToJsonReader +import com.apollographql.apollo.testing.pathToUtf8 + +@Suppress("DEPRECATION") +fun testFixtureToUtf8(name: String) = pathToUtf8("normalized-cache/testFixtures/$name") + +@Suppress("DEPRECATION") +fun testFixtureToJsonReader(name: String) = pathToJsonReader("normalized-cache/testFixtures/$name") diff --git a/tests/normalized-cache/src/commonTest/kotlin/WatcherErrorHandlingTest.kt b/tests/normalized-cache/src/commonTest/kotlin/WatcherErrorHandlingTest.kt new file mode 100644 index 00000000..03b0db08 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/WatcherErrorHandlingTest.kt @@ -0,0 +1,216 @@ +package test + +import app.cash.turbine.test +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.exception.ApolloHttpException +import com.apollographql.apollo.exception.CacheMissException +import com.apollographql.apollo.testing.awaitElement +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.refetchPolicy +import com.apollographql.cache.normalized.store +import com.apollographql.cache.normalized.watch +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueError +import com.apollographql.mockserver.enqueueString +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import normalizer.EpisodeHeroNameQuery +import normalizer.type.Episode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class WatcherErrorHandlingTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private suspend fun setUp() { + store = ApolloStore(MemoryCacheFactory(), cacheKeyGenerator = IdCacheKeyGenerator()) + mockServer = MockServer() + apolloClient = ApolloClient.Builder().serverUrl(mockServer.url()).store(store).build() + } + + private fun tearDown() { + mockServer.close() + } + + /** + * watch() should behave just like toFlow() in the absence of cache writes + */ + @Test + fun fetchEmitsAllErrors() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueError(statusCode = 500) + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.CacheFirst) + .watch() + .test { + assertIs(awaitItem().exception) + assertIs(awaitItem().exception) + cancelAndIgnoreRemainingEvents() + } + + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.CacheOnly) + .watch() + .test { + assertIs(awaitItem().exception) + cancelAndIgnoreRemainingEvents() + } + + mockServer.enqueueError(statusCode = 500) + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.NetworkFirst) + .watch() + .test { + assertIs(awaitItem().exception) + assertIs(awaitItem().exception) + cancelAndIgnoreRemainingEvents() + } + + mockServer.enqueueError(statusCode = 500) + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .watch() + .test { + assertIs(awaitItem().exception) + cancelAndIgnoreRemainingEvents() + } + + mockServer.enqueueError(statusCode = 500) + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.CacheAndNetwork) + .watch() + .test { + assertIs(awaitItem().exception) + assertIs(awaitItem().exception) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun refetchEmitsAllErrors() = runTest(before = { setUp() }, after = { tearDown() }) { + val channel = Channel>() + + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + + // The first query should get a "R2-D2" name + val job = launch { + mockServer.enqueueString(testFixtureToUtf8("EpisodeHeroNameResponseWithId.json")) + apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkOnly) + .refetchPolicy(FetchPolicy.NetworkOnly) + .watch() + .collect { + channel.send(it) + } + } + @Suppress("DEPRECATION") + assertEquals(channel.awaitElement().data?.hero?.name, "R2-D2") + + // Another newer call gets updated information with "Artoo" + // Due to .refetchPolicy(FetchPolicy.NetworkOnly), a subsequent call will be executed in watch() + // we enqueue an error so a network exception is emitted + mockServer.enqueueString(testFixtureToUtf8("EpisodeHeroNameResponseNameChange.json")) + mockServer.enqueueError(statusCode = 500) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + @Suppress("DEPRECATION") + assertIs(channel.awaitElement().exception) + job.cancel() + } + + @Test + fun fetchEmitsExceptions() = runTest(before = { setUp() }, after = { tearDown() }) { + mockServer.enqueueError(statusCode = 500) + assertIs( + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.CacheFirst) + .watch() + .first() + .exception + ) + + assertIs( + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.CacheOnly) + .watch() + .first() + .exception + ) + + mockServer.enqueueError(statusCode = 500) + assertIs( + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.NetworkFirst) + .watch() + .first() + .exception + ) + + mockServer.enqueueError(statusCode = 500) + assertIs( + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .watch() + .first() + .exception + ) + + mockServer.enqueueError(statusCode = 500) + assertIs( + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.CacheAndNetwork) + .watch() + .first() + .exception + ) + } + + @Test + fun refetchEmitsExceptions() = runTest(before = { setUp() }, after = { tearDown() }) { + val channel = Channel>() + + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + + var throwable: Throwable? = null + + // The first query should get a "R2-D2" name + val job = launch { + mockServer.enqueueString(testFixtureToUtf8("EpisodeHeroNameResponseWithId.json")) + apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkOnly) + .refetchPolicy(FetchPolicy.NetworkOnly) + .watch() + .catch { throwable = it } + .collect { + channel.send(it) + } + } + @Suppress("DEPRECATION") + assertEquals(channel.awaitElement().data?.hero?.name, "R2-D2") + + // Another newer call gets updated information with "Artoo" + // Due to .refetchPolicy(FetchPolicy.NetworkOnly), a subsequent call will be executed in watch() + // we enqueue an error so a network exception is emitted + mockServer.enqueueString(testFixtureToUtf8("EpisodeHeroNameResponseNameChange.json")) + mockServer.enqueueError(statusCode = 500) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + @Suppress("DEPRECATION") + assertIs(channel.awaitElement().exception) + + assertNull(throwable) + + job.cancel() + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/WatcherTest.kt b/tests/normalized-cache/src/commonTest/kotlin/WatcherTest.kt new file mode 100644 index 00000000..a6ef0506 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/WatcherTest.kt @@ -0,0 +1,667 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.composeJsonResponse +import com.apollographql.apollo.exception.ApolloNetworkException +import com.apollographql.apollo.exception.CacheMissException +import com.apollographql.apollo.testing.QueueTestNetworkTransport +import com.apollographql.apollo.testing.enqueueTestNetworkError +import com.apollographql.apollo.testing.enqueueTestResponse +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.api.CacheHeaders +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import com.apollographql.cache.normalized.refetchPolicy +import com.apollographql.cache.normalized.store +import com.apollographql.cache.normalized.watch +import com.apollographql.mockserver.MockResponse +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import normalizer.EpisodeHeroNameQuery +import normalizer.EpisodeHeroNameWithIdQuery +import normalizer.HeroAndFriendsNamesWithIDsQuery +import normalizer.StarshipByIdQuery +import normalizer.type.Episode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.minutes + +class WatcherTest { + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + + private fun setUp() { + store = ApolloStore(MemoryCacheFactory(), cacheKeyGenerator = IdCacheKeyGenerator()) + apolloClient = ApolloClient.Builder().networkTransport(QueueTestNetworkTransport()).store(store).build() + } + + private val episodeHeroNameData = EpisodeHeroNameQuery.Data(EpisodeHeroNameQuery.Hero("R2-D2")) + private val episodeHeroNameChangedData = EpisodeHeroNameQuery.Data(EpisodeHeroNameQuery.Hero("Artoo")) + private val episodeHeroNameChangedTwoData = EpisodeHeroNameQuery.Data(EpisodeHeroNameQuery.Hero("ArTwo")) + + private val episodeHeroNameWithIdData = EpisodeHeroNameWithIdQuery.Data(EpisodeHeroNameWithIdQuery.Hero("2001", "R2-D2")) + + + private val heroAndFriendsNamesWithIDsData = HeroAndFriendsNamesWithIDsQuery.Data( + HeroAndFriendsNamesWithIDsQuery.Hero("2001", "R2-D2", listOf( + HeroAndFriendsNamesWithIDsQuery.Friend("1000", "Luke Skywalker"), + HeroAndFriendsNamesWithIDsQuery.Friend("1002", "Han Solo"), + HeroAndFriendsNamesWithIDsQuery.Friend("1003", "Leia Organa"), + ) + ) + ) + private val heroAndFriendsNamesWithIDsNameChangedData = HeroAndFriendsNamesWithIDsQuery.Data( + HeroAndFriendsNamesWithIDsQuery.Hero("1000", "Luke Skywalker", listOf( + HeroAndFriendsNamesWithIDsQuery.Friend("2001", "Artoo"), + HeroAndFriendsNamesWithIDsQuery.Friend("1002", "Han Solo"), + HeroAndFriendsNamesWithIDsQuery.Friend("1003", "Leia Organa"), + ) + ) + ) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun myRunTest(block: suspend CoroutineScope.() -> Unit) { + kotlinx.coroutines.test.runTest(timeout = 10.minutes) { + withContext(Dispatchers.Default.limitedParallelism(1)) { + block() + } + } + } + + /** + * Executing the same query out of band should update the watcher + * + * Also, this test checks that the watcher gets control fast enough to subscribe to + * cache changes + */ + @Test + fun sameQueryTriggersWatcher() = myRunTest { + setUp() + + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + val channel = Channel() + + repeat(10000) { + // Enqueue responses + apolloClient.enqueueTestResponse(query, episodeHeroNameData) + apolloClient.enqueueTestResponse(query, episodeHeroNameChangedData) + + val job = launch(start = CoroutineStart.UNDISPATCHED) { + val flow = apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).watch() + flow.collect { + channel.send(it.data) + } + } + + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + // Another newer call gets updated information with "Artoo" + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + assertEquals(channel.awaitElement()?.hero?.name, "Artoo") + + job.cancel() + } + } + + @Test + fun cacheMissesAreEmitted() = runTest(before = { setUp() }) { + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + val channel = Channel() + + apolloClient.enqueueTestResponse(query, episodeHeroNameData) + + val job = launch { + apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheOnly) + .watch() + .collect { + channel.send(it.data) + } + } + + val data = channel.awaitElement() + assertNull(data) + + // Update the cache + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + job.cancel() + } + + + /** + * Writing to the store out of band should update the watcher + */ + @Test + fun storeWriteTriggersWatcher() = runTest(before = { setUp() }) { + val channel = Channel() + val operation = EpisodeHeroNameWithIdQuery(Episode.EMPIRE) + apolloClient.enqueueTestResponse(operation, episodeHeroNameWithIdData) + val job = launch { + apolloClient.query(operation).watch().collect { + channel.send(it.data) + } + } + + // Cache miss is emitted first (null data) + assertNull(channel.awaitElement()) + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + // Someone writes to the store directly + val data = EpisodeHeroNameWithIdQuery.Data( + EpisodeHeroNameWithIdQuery.Hero( + "2001", + "Artoo" + ) + ) + + store.writeOperation(operation, data, CustomScalarAdapters.Empty, CacheHeaders.NONE).also { + store.publish(it) + } + + assertEquals(channel.awaitElement()?.hero?.name, "Artoo") + + job.cancel() + } + + /** + * A new query updates the store with data that is the same as the one originally seen by the watcher + */ + @Test + fun noChangeSameQuery() = runTest(before = { setUp() }) { + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + val channel = Channel() + + // The first query should get a "R2-D2" name + apolloClient.enqueueTestResponse(query, episodeHeroNameData) + val job = launch { + apolloClient.query(query).watch().collect { + channel.send(it.data) + } + } + + // Cache miss is emitted first (null data) + assertNull(channel.awaitElement()) + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + // Another newer call gets the same name (R2-D2) + apolloClient.enqueueTestResponse(query, episodeHeroNameData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + channel.assertEmpty() + + job.cancel() + } + + /** + * A new query that contains overlapping fields with the watched query should trigger the watcher + */ + @Test + fun differentQueryTriggersWatcher() = runTest(before = { setUp() }) { + val channel = Channel() + + // The first query should get a "R2-D2" name + val episodeHeroNameWithIdQuery = EpisodeHeroNameWithIdQuery(Episode.EMPIRE) + apolloClient.enqueueTestResponse(episodeHeroNameWithIdQuery, episodeHeroNameWithIdData) + val job = launch { + apolloClient.query(episodeHeroNameWithIdQuery).watch().collect { + channel.send(it.data) + } + } + + // Cache miss is emitted first (null data) + assertNull(channel.awaitElement()) + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + // Another newer call gets updated information with "Artoo" + val heroAndFriendsNamesWithIDsQuery = HeroAndFriendsNamesWithIDsQuery(Episode.NEWHOPE) + apolloClient.enqueueTestResponse(heroAndFriendsNamesWithIDsQuery, heroAndFriendsNamesWithIDsNameChangedData) + apolloClient.query(heroAndFriendsNamesWithIDsQuery) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + + + assertEquals(channel.awaitElement()?.hero?.name, "Artoo") + + job.cancel() + } + + /** + * Same as noChangeSameQuery with different queries + */ + @Test + fun noChangeDifferentQuery() = runTest(before = { setUp() }) { + val channel = Channel() + + // The first query should get a "R2-D2" name + val episodeHeroNameQuery = EpisodeHeroNameQuery(Episode.EMPIRE) + apolloClient.enqueueTestResponse(episodeHeroNameQuery, episodeHeroNameData) + val job = launch { + apolloClient.query(episodeHeroNameQuery).watch().collect { + channel.send(it.data) + } + } + + // Cache miss is emitted first (null data) + assertNull(channel.awaitElement()) + assertEquals(channel.receive()?.hero?.name, "R2-D2") + + // Another newer call gets the same information + val heroAndFriendsNamesWithIDsQuery = HeroAndFriendsNamesWithIDsQuery(Episode.NEWHOPE) + apolloClient.enqueueTestResponse(heroAndFriendsNamesWithIDsQuery, heroAndFriendsNamesWithIDsData) + apolloClient.query(heroAndFriendsNamesWithIDsQuery) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + + channel.assertEmpty() + + job.cancel() + } + + /** + * A test to test refetching with a NetworkOnly refetchPolicy. On every change, the watcher should get new information + * from the network + */ + @Test + fun networkRefetchPolicy() = runTest(before = { setUp() }) { + val channel = Channel() + + // The first query should get a "R2-D2" name + val episodeHeroNameQuery = EpisodeHeroNameQuery(Episode.EMPIRE) + apolloClient.enqueueTestResponse(episodeHeroNameQuery, episodeHeroNameData) + val job = launch { + apolloClient.query(episodeHeroNameQuery) + .fetchPolicy(FetchPolicy.NetworkOnly) + .refetchPolicy(FetchPolicy.NetworkOnly) + .watch().collect { + channel.send(it.data) + } + } + + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + // Enqueue 2 responses. + // - The first one will be for the query just below and contains "Artoo" + // - The second one will be for the watcher refetch and contains "ArTwo" + apolloClient.enqueueTestResponse(episodeHeroNameQuery, episodeHeroNameChangedData) + apolloClient.enqueueTestResponse(episodeHeroNameQuery, episodeHeroNameChangedTwoData) + // - Because the network only watcher will also store in the cache a different name value, it will trigger itself again + // Enqueue a stable response to avoid errors during tests + apolloClient.enqueueTestResponse(episodeHeroNameQuery, episodeHeroNameChangedTwoData) + + // Trigger a refetch + val response = apolloClient.query(episodeHeroNameQuery) + .fetchPolicy(FetchPolicy.NetworkOnly) + .execute() + assertEquals(response.data?.hero?.name, "Artoo") + + // The watcher should refetch from the network and now see "ArTwo" + assertEquals("ArTwo", channel.awaitElement()?.hero?.name) + + job.cancel() + } + + + @Test + fun nothingReceivedWhenCancelled() = runTest(before = { setUp() }) { + val channel = Channel() + + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + apolloClient.enqueueTestResponse(query, episodeHeroNameData) + val job = launch { + apolloClient.query(query) + .fetchPolicy(FetchPolicy.NetworkOnly) + .refetchPolicy(FetchPolicy.NetworkOnly) + .watch() + .collect { + channel.send(it.data) + } + } + job.cancelAndJoin() + + channel.assertEmpty() + } + + /** + * Doing the initial query as cache only will detect when the query becomes available + */ + @Test + fun cacheOnlyFetchPolicy() = runTest(before = { setUp() }) { + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + val channel = Channel() + + // This will initially miss as the cache should be empty + val job = launch { + apolloClient.query(query) + .watch(null) + .collect { + channel.send(it.data) + } + } + + // Because subscribe is called from a background thread, give some time to be effective + delay(500) + + // Another newer call gets updated information with "R2-D2" + apolloClient.enqueueTestResponse(query, episodeHeroNameData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + job.cancel() + } + + @Test + fun queryWatcherWithCacheOnlyNeverGoesToTheNetwork() = runTest(before = { setUp() }) { + val channel = Channel>(capacity = Channel.UNLIMITED) + val job = launch { + + apolloClient.query(EpisodeHeroNameQuery(Episode.EMPIRE)) + .fetchPolicy(FetchPolicy.CacheOnly) + .refetchPolicy(FetchPolicy.CacheOnly) + .watch().collect { + channel.send(it) + } + } + + // execute a query that doesn't share any key with the main query + // that will trigger a refetch that shouldn't throw + apolloClient.query(StarshipByIdQuery("Starship1")) + + // Should see 1 cache miss values + assertIs(channel.awaitElement().exception) + channel.assertEmpty() + + job.cancel() + } + + @Test + fun watchCacheOrNetwork() = runTest(before = { setUp() }) { + val channel = Channel(capacity = Channel.UNLIMITED) + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + + // 1. get the result from the cache if any, if not, get it from the network + // 2. observe new data + + // The first query should get a "R2-D2" name + apolloClient.enqueueTestResponse(query, episodeHeroNameData) + + val job = launch { + // 1. query (will be a cache miss since the cache starts empty), then watch + apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheFirst) + .refetchPolicy(FetchPolicy.CacheOnly) + .watch() + .collect { + channel.send(it.data) + } + } + + // Cache miss is emitted first (null data) + assertNull(channel.awaitElement()) + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + // Another newer call gets updated information with "Artoo" + apolloClient.enqueueTestResponse(query, episodeHeroNameChangedData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + assertEquals(channel.awaitElement()?.hero?.name, "Artoo") + + job.cancel() + } + + @Test + fun watchCacheAndNetworkManual() = runTest(before = { setUp() }) { + val channel = Channel(capacity = Channel.UNLIMITED) + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + + // 1. get the result from the cache if any + // 2. get fresh data from the network + // 3. observe new data + + // Set up the cache with a "R2-D2" name + apolloClient.enqueueTestResponse(query, episodeHeroNameData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // Prepare next call to get "Artoo" + apolloClient.enqueueTestResponse(query, episodeHeroNameChangedData) + + val job = launch { + apolloClient.query(query) + .fetchPolicy(FetchPolicy.CacheAndNetwork) + .refetchPolicy(FetchPolicy.CacheOnly) + .watch() + .collect { + channel.send(it.data) + } + } + // 1. Value from the cache + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + // 2. Value from the network + assertEquals(channel.awaitElement()?.hero?.name, "Artoo") + + // Another newer call updates the cache with "ArTwo" + apolloClient.enqueueTestResponse(query, episodeHeroNameChangedTwoData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // 3. Value from watching the cache + assertEquals(channel.awaitElement()?.hero?.name, "ArTwo") + + job.cancel() + } + + /** + * watchCacheAndNetwork() with cached value and no network error + */ + @Test + fun watchCacheAndNetwork() = runTest(before = { setUp() }) { + val channel = Channel(capacity = Channel.UNLIMITED) + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + + // Set up the cache with a "R2-D2" name + apolloClient.enqueueTestResponse(query, episodeHeroNameData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // Prepare next call to get "Artoo" + apolloClient.enqueueTestResponse(query, episodeHeroNameChangedData) + + val job = launch { + apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).watch() + .collect { + channel.send(it.data) + } + } + // 1. Value from the cache + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + // 2. Value from the network + assertEquals(channel.awaitElement(5000)?.hero?.name, "Artoo") + + // Another newer call updates the cache with "ArTwo" + apolloClient.enqueueTestResponse(query, episodeHeroNameChangedTwoData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // 3. Value from watching the cache + assertEquals(channel.awaitElement()?.hero?.name, "ArTwo") + + job.cancel() + } + + /** + * watchCacheAndNetwork() with a cache miss + */ + @Test + fun watchCacheAndNetworkWithCacheMiss() = runTest(before = { setUp() }) { + val channel = Channel(capacity = Channel.UNLIMITED) + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + + // Prepare next call to get "Artoo" + apolloClient.enqueueTestResponse(query, episodeHeroNameChangedData) + + val job = launch { + apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).watch() + .collect { + channel.send(it.data) + } + } + // 0. Cache miss (null data) + assertNull(channel.awaitElement()) + // 1. Value from the network + assertEquals(channel.awaitElement(5000)?.hero?.name, "Artoo") + + // Another newer call updates the cache with "ArTwo" + apolloClient.enqueueTestResponse(query, episodeHeroNameChangedTwoData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // 2. Value from watching the cache + assertEquals(channel.awaitElement()?.hero?.name, "ArTwo") + + job.cancel() + } + + @Test + fun cacheAndNetworkEmitsCacheImmediately() = runTest { + // This doesn't use TestNetworkTransport because we need timing control + val mockServer = MockServer() + val apolloClient = ApolloClient.Builder() + .normalizedCache(MemoryCacheFactory()) + .serverUrl(mockServer.url()) + .build() + + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + + // Set up the cache with a "R2-D2" name + mockServer.enqueueString(query.composeJsonResponse(episodeHeroNameData)) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // Prepare next call to be a network error + mockServer.enqueue(MockResponse.Builder().delayMillis(Long.MAX_VALUE).build()) + + withTimeout(500) { + // make sure we get the cache only result + val response = apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).watch().first() + assertEquals("R2-D2", response.data?.hero?.name) + } + + mockServer.close() + apolloClient.close() + } + + + /** + * watchCacheAndNetwork() with a network error on the initial call + */ + @Test + fun watchCacheAndNetworkWithNetworkError() = runTest(before = { setUp() }) { + val channel = Channel(capacity = Channel.UNLIMITED) + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + + // Set up the cache with a "R2-D2" name + apolloClient.enqueueTestResponse(query, episodeHeroNameData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // Prepare next call to be a network error + apolloClient.enqueueTestNetworkError() + + val job = launch { + apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).watch() + .collect { + channel.send(it.data) + } + } + // 1. Value from the cache + assertEquals(channel.awaitElement()?.hero?.name, "R2-D2") + + // 2. Exception from the network (null data) + assertNull(channel.awaitElement()) + channel.assertEmpty() + + // Another newer call updates the cache with "ArTwo" + apolloClient.enqueueTestResponse(query, episodeHeroNameChangedTwoData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // 3. Value from watching the cache + assertEquals(channel.awaitElement()?.hero?.name, "ArTwo") + + job.cancel() + } + + /** + * watchCacheAndNetwork() with a cache error AND a network error on the initial call + */ + @Test + fun watchCacheAndNetworkWithCacheAndNetworkError() = runTest(before = { setUp() }) { + val channel = Channel>(capacity = Channel.UNLIMITED) + val query = EpisodeHeroNameQuery(Episode.EMPIRE) + + // Prepare next call to be a network error + apolloClient.enqueueTestNetworkError() + + val job = launch { + apolloClient.query(query).fetchPolicy(FetchPolicy.CacheAndNetwork).watch() + .collect { + channel.send(it) + } + } + + // We ge the cache miss and the network error + assertIs(channel.awaitElement().exception) + assertIs(channel.awaitElement().exception) + + // Another newer call updates the cache with "ArTwo" + apolloClient.enqueueTestResponse(query, episodeHeroNameChangedTwoData) + apolloClient.query(query).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + // Value from watching the cache + assertEquals(channel.awaitElement().data?.hero?.name, "ArTwo") + + job.cancel() + } + +} + +internal suspend fun Channel.assertCount(count: Int) { + repeat(count) { + awaitElement() + } + assertEmpty() +} + +internal suspend fun Channel.awaitElement(timeoutMillis: Long = 30000) = withTimeout(timeoutMillis) { + receive() +} + +internal suspend fun Channel.assertEmpty(timeoutMillis: Long = 300): Unit { + try { + withTimeout(timeoutMillis) { + receive() + } + error("An item was unexpectedly received") + } catch (_: TimeoutCancellationException) { + // nothing + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/circular/CircularCacheReadTest.kt b/tests/normalized-cache/src/commonTest/kotlin/circular/CircularCacheReadTest.kt new file mode 100644 index 00000000..909c9695 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/circular/CircularCacheReadTest.kt @@ -0,0 +1,36 @@ +package test.circular + +import circular.GetUserQuery +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import kotlin.test.Test +import kotlin.test.assertEquals + +class CircularCacheReadTest { + @Test + fun circularReferenceDoesNotStackOverflow() = runTest { + val store = ApolloStore(MemoryCacheFactory()) + + val operation = GetUserQuery() + + /** + * Create a record that references itself. It should not create a stack overflow + */ + val data = GetUserQuery.Data( + GetUserQuery.User( + "42", + GetUserQuery.Friend( + "42", + "User" + ), + "User", + ) + ) + + store.writeOperation(operation, data) + val result = store.readOperation(operation, customScalarAdapters = CustomScalarAdapters.Empty).data + assertEquals("42", result.user.friend.id) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/declarativecache/DeclarativeCacheTest.kt b/tests/normalized-cache/src/commonTest/kotlin/declarativecache/DeclarativeCacheTest.kt new file mode 100644 index 00000000..5aa5a47a --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/declarativecache/DeclarativeCacheTest.kt @@ -0,0 +1,130 @@ +package test.declarativecache + +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.api.CacheKey +import com.apollographql.cache.normalized.api.CacheResolver +import com.apollographql.cache.normalized.api.FieldPolicyCacheResolver +import com.apollographql.cache.normalized.api.ResolverContext +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import declarativecache.GetAuthorQuery +import declarativecache.GetBookQuery +import declarativecache.GetBooksQuery +import declarativecache.GetOtherBookQuery +import declarativecache.GetOtherLibraryQuery +import declarativecache.GetPromoAuthorQuery +import declarativecache.GetPromoBookQuery +import declarativecache.GetPromoLibraryQuery +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeclarativeCacheTest { + + @Test + fun typePolicyIsWorking() = runTest { + val store = ApolloStore(MemoryCacheFactory()) + + // Write a book at the "promo" path + val promoOperation = GetPromoBookQuery() + val promoData = GetPromoBookQuery.Data(GetPromoBookQuery.PromoBook("Promo", "42", "Book")) + store.writeOperation(promoOperation, promoData) + + // Overwrite the book title through the "other" path + val otherOperation = GetOtherBookQuery() + val otherData = GetOtherBookQuery.Data(GetOtherBookQuery.OtherBook("42", "Other", "Book")) + store.writeOperation(otherOperation, otherData) + + // Get the "promo" book again, the title must be updated + val data = store.readOperation(promoOperation, CustomScalarAdapters.Empty).data + + assertEquals("Other", data.promoBook?.title) + } + + @Test + fun fallbackIdIsWorking() = runTest { + val store = ApolloStore(MemoryCacheFactory()) + + // Write a library at the "promo" path + val promoOperation = GetPromoLibraryQuery() + val promoData = GetPromoLibraryQuery.Data(GetPromoLibraryQuery.PromoLibrary("PromoAddress", "3", "Library")) + store.writeOperation(promoOperation, promoData) + + // Overwrite the library address through the "other" path + val otherOperation = GetOtherLibraryQuery() + val otherData = GetOtherLibraryQuery.Data(GetOtherLibraryQuery.OtherLibrary("3", "OtherAddress", "Library")) + store.writeOperation(otherOperation, otherData) + + // Get the "promo" library again, the address must be updated + val data = store.readOperation(promoOperation, CustomScalarAdapters.Empty).data + + assertEquals("OtherAddress", data.promoLibrary?.address) + } + + @Test + fun fieldPolicyIsWorking() = runTest { + val store = ApolloStore(MemoryCacheFactory()) + + val bookQuery1 = GetPromoBookQuery() + val bookData1 = GetPromoBookQuery.Data(GetPromoBookQuery.PromoBook("Promo", "42", "Book")) + store.writeOperation(bookQuery1, bookData1) + + val bookQuery2 = GetBookQuery("42") + val bookData2 = store.readOperation(bookQuery2, CustomScalarAdapters.Empty).data + + assertEquals("Promo", bookData2.book?.title) + + val authorQuery1 = GetPromoAuthorQuery() + val authorData1 = GetPromoAuthorQuery.Data( + GetPromoAuthorQuery.PromoAuthor( + "Pierre", + "Bordage", + "Author" + ) + ) + + store.writeOperation(authorQuery1, authorData1) + + val authorQuery2 = GetAuthorQuery("Pierre", "Bordage") + val authorData2 = store.readOperation(authorQuery2, CustomScalarAdapters.Empty).data + + assertEquals("Pierre", authorData2.author?.firstName) + assertEquals("Bordage", authorData2.author?.lastName) + } + + @Test + fun canResolveListProgrammatically() = runTest { + val cacheResolver = object : CacheResolver { + override fun resolveField(context: ResolverContext): Any? { + val fieldName = context.field + if (fieldName.name == "books") { + @Suppress("UNCHECKED_CAST") + val isbns = fieldName.argumentValue("isbns", context.variables).getOrThrow() as? List + if (isbns != null) { + return isbns.map { CacheKey(fieldName.type.rawType().name, listOf(it)) } + } + } + + return FieldPolicyCacheResolver.resolveField(context) + } + } + val store = ApolloStore(MemoryCacheFactory(), cacheResolver = cacheResolver) + + val promoOperation = GetPromoBookQuery() + store.writeOperation(promoOperation, GetPromoBookQuery.Data(GetPromoBookQuery.PromoBook("Title1", "1", "Book"))) + store.writeOperation(promoOperation, GetPromoBookQuery.Data(GetPromoBookQuery.PromoBook("Title2", "2", "Book"))) + store.writeOperation(promoOperation, GetPromoBookQuery.Data(GetPromoBookQuery.PromoBook("Title3", "3", "Book"))) + store.writeOperation(promoOperation, GetPromoBookQuery.Data(GetPromoBookQuery.PromoBook("Title4", "4", "Book"))) + + var operation = GetBooksQuery(listOf("4", "1")) + var data = store.readOperation(operation, CustomScalarAdapters.Empty).data + + assertEquals("Title4", data.books.get(0).title) + assertEquals("Title1", data.books.get(1).title) + + operation = GetBooksQuery(listOf("3")) + data = store.readOperation(operation, CustomScalarAdapters.Empty).data + + assertEquals("Title3", data.books.get(0).title) + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/fragmentnormalizer/FragmentNormalizerTest.kt b/tests/normalized-cache/src/commonTest/kotlin/fragmentnormalizer/FragmentNormalizerTest.kt new file mode 100644 index 00000000..0221d5f9 --- /dev/null +++ b/tests/normalized-cache/src/commonTest/kotlin/fragmentnormalizer/FragmentNormalizerTest.kt @@ -0,0 +1,105 @@ +package test.fragmentnormalizer + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.api.CacheKey +import com.apollographql.cache.normalized.api.IdCacheKeyGenerator +import com.apollographql.cache.normalized.api.normalize +import com.apollographql.cache.normalized.apolloStore +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import fragmentnormalizer.fragment.ConversationFragment +import fragmentnormalizer.fragment.ConversationFragmentImpl +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +class FragmentNormalizerTest { + @Test + fun test() = runTest { + val cacheFactory = MemoryCacheFactory() + + val apolloClient = ApolloClient.Builder() + .serverUrl("https:/example.com") + .normalizedCache(cacheFactory) + .build() + + var fragment1 = ConversationFragment( + "1", + ConversationFragment.Author( + "John Doe", + ), + false + ) + + /** + * This is not using .copy() because this test also runs in Java and Java doesn't have copy() + */ + val fragment1Read = ConversationFragment( + "1", + ConversationFragment.Author( + "John Doe", + ), + true + ) + val fragment2 = ConversationFragment( + "2", + ConversationFragment.Author( + "Yayyy Pancakes!", + ), + false + ) + + /** + * This is not using .copy() because this test also runs in Java and Java doesn't have copy() + */ + val fragment2Read = ConversationFragment( + "2", + ConversationFragment.Author( + "Yayyy Pancakes!", + ), + true + ) + apolloClient.apolloStore.writeFragment( + ConversationFragmentImpl(), + CacheKey(fragment1.id), + fragment1Read, + CustomScalarAdapters.Empty + ) + + apolloClient.apolloStore.writeFragment( + ConversationFragmentImpl(), + CacheKey(fragment2.id), + fragment2Read, + CustomScalarAdapters.Empty + ) + + fragment1 = apolloClient.apolloStore.readFragment( + ConversationFragmentImpl(), + CacheKey(fragment1.id), + ).data + + assertEquals("John Doe", fragment1.author.fullName) + } + + @Test + fun rootKeyIsNotSkipped() = runTest { + val fragment = ConversationFragment( + "1", + ConversationFragment.Author( + "John Doe", + ), + false + ) + + val records = ConversationFragmentImpl().normalize( + fragment, + CustomScalarAdapters.Empty, + IdCacheKeyGenerator(), + rootKey = "1", + ) + + assertContains(records.keys, "1.author") + } +} diff --git a/tests/normalized-cache/src/commonTest/kotlin/MemoryCacheOnlyTest.kt b/tests/normalized-cache/src/concurrentTest/kotlin/MemoryCacheOnlyTest.kt similarity index 98% rename from tests/normalized-cache/src/commonTest/kotlin/MemoryCacheOnlyTest.kt rename to tests/normalized-cache/src/concurrentTest/kotlin/MemoryCacheOnlyTest.kt index b42b4e79..481bab89 100644 --- a/tests/normalized-cache/src/commonTest/kotlin/MemoryCacheOnlyTest.kt +++ b/tests/normalized-cache/src/concurrentTest/kotlin/MemoryCacheOnlyTest.kt @@ -1,5 +1,3 @@ -package test - import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.exception.CacheMissException import com.apollographql.apollo.testing.QueueTestNetworkTransport @@ -15,6 +13,7 @@ import com.apollographql.cache.normalized.memoryCacheOnly import com.apollographql.cache.normalized.sql.SqlNormalizedCache import com.apollographql.cache.normalized.sql.SqlNormalizedCacheFactory import com.apollographql.cache.normalized.store +import main.GetUserQuery import kotlin.reflect.KClass import kotlin.test.Test import kotlin.test.assertEquals diff --git a/tests/normalized-cache/src/jvmTest/kotlin/ApqCacheTest.kt b/tests/normalized-cache/src/jvmTest/kotlin/ApqCacheTest.kt new file mode 100644 index 00000000..67034bc5 --- /dev/null +++ b/tests/normalized-cache/src/jvmTest/kotlin/ApqCacheTest.kt @@ -0,0 +1,46 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.composeJsonResponse +import com.apollographql.apollo.api.http.HttpMethod +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import normalizer.HeroNameQuery +import org.junit.Test +import kotlin.test.fail + +class ApqCacheTest { + /** + * https://github.com/apollographql/apollo-kotlin/issues/4617 + */ + @Test + fun apqAndCache() = runTest { + val mockServer = MockServer() + + val data = HeroNameQuery.Data(HeroNameQuery.Hero("R2-D2")) + val query = HeroNameQuery() + + mockServer.enqueueString(query.composeJsonResponse(data)) + mockServer.enqueueString(query.composeJsonResponse(data)) + + try { + ApolloClient.Builder() + .serverUrl(mockServer.url()) + // Note that mutations will always be sent as POST requests, regardless of these settings, as to avoid hitting caches. + .autoPersistedQueries( + // For the initial hashed query that does not send the actual Graphql document + httpMethodForHashedQueries = HttpMethod.Get, + // For the follow-up query that sends the full document if the initial hashed query was not found + httpMethodForDocumentQueries = HttpMethod.Get + ) + .normalizedCache(normalizedCacheFactory = MemoryCacheFactory(10 * 1024 * 1024)) + .build() + fail("An exception was expected") + } catch (e: Exception) { + check(e.message!!.contains("Apollo: the normalized cache must be configured before the auto persisted queries")) + } + } +} diff --git a/tests/normalized-cache/src/jvmTest/kotlin/CacheConcurrencyTest.kt b/tests/normalized-cache/src/jvmTest/kotlin/CacheConcurrencyTest.kt new file mode 100644 index 00000000..22628fd4 --- /dev/null +++ b/tests/normalized-cache/src/jvmTest/kotlin/CacheConcurrencyTest.kt @@ -0,0 +1,44 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.QueueTestNetworkTransport +import com.apollographql.apollo.testing.enqueueTestResponse +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import normalizer.CharacterNameByIdQuery +import java.util.concurrent.Executors +import kotlin.test.Test + +class CacheConcurrencyTest { + + @Test + fun storeConcurrently() = runTest { + val store = ApolloStore(MemoryCacheFactory(maxSizeBytes = 1000)) + val executor = Executors.newFixedThreadPool(10) + val dispatcher = executor.asCoroutineDispatcher() + + val apolloClient = ApolloClient.Builder() + .networkTransport(QueueTestNetworkTransport()) + .store(store) + .dispatcher(dispatcher) + .build() + + val concurrency = 100 + + 0.until(concurrency).map { + launch(dispatcher) { + val query = CharacterNameByIdQuery((it / 2).toString()) + apolloClient.enqueueTestResponse(query, CharacterNameByIdQuery.Data(CharacterNameByIdQuery.Character(name = it.toString()))) + apolloClient.query(query).execute() + } + }.joinAll() + + executor.shutdown() + println(store.dump().values.toList()[1].map { (k, v) -> "$k -> ${v.fields}" }.joinToString("\n")) + } +} diff --git a/tests/normalized-cache/src/jvmTest/kotlin/CacheMissLoggingInterceptorTest.kt b/tests/normalized-cache/src/jvmTest/kotlin/CacheMissLoggingInterceptorTest.kt new file mode 100644 index 00000000..403db40c --- /dev/null +++ b/tests/normalized-cache/src/jvmTest/kotlin/CacheMissLoggingInterceptorTest.kt @@ -0,0 +1,77 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.logCacheMisses +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import normalizer.HeroAppearsInQuery +import normalizer.HeroNameQuery +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * We're only doing this on the JVM because it saves time and the CacheMissLoggingInterceptor + * touches mutable data from different threads + */ +class CacheMissLoggingInterceptorTest { + + @Test + fun cacheMissLogging() = runTest { + val recordedLogs = mutableListOf() + val mockServer = MockServer() + val apolloClient = ApolloClient.Builder() + .serverUrl(mockServer.url()) + .logCacheMisses { + synchronized(recordedLogs) { + recordedLogs.add(it) + } + } + .normalizedCache(MemoryCacheFactory()) + .build() + + mockServer.enqueueString(""" + { + "data": { + "hero": { + "name": "Luke" + } + } + } + """.trimIndent() + ) + apolloClient.query(HeroNameQuery()).execute() + assertNotNull( + apolloClient.query(HeroAppearsInQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute().exception + ) + + assertEquals( + listOf( + "Object 'QUERY_ROOT' has no field named 'hero'", + "Object 'hero' has no field named 'appearsIn'" + ), + recordedLogs + ) + mockServer.close() + apolloClient.close() + } + + @Test + fun logCacheMissesMustBeCalledFirst() { + try { + ApolloClient.Builder() + .normalizedCache(MemoryCacheFactory()) + .logCacheMisses() + .build() + error("We expected an exception") + } catch (e: Exception) { + assertTrue(e.message?.contains("logCacheMisses() must be called before setting up your normalized cache") == true) + } + } +} diff --git a/tests/normalized-cache/src/jvmTest/kotlin/WriteToCacheAsynchronouslyTest.kt b/tests/normalized-cache/src/jvmTest/kotlin/WriteToCacheAsynchronouslyTest.kt new file mode 100644 index 00000000..0562426f --- /dev/null +++ b/tests/normalized-cache/src/jvmTest/kotlin/WriteToCacheAsynchronouslyTest.kt @@ -0,0 +1,86 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.ApolloStore +import com.apollographql.cache.normalized.api.CacheHeaders +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.store +import com.apollographql.cache.normalized.writeToCacheAsynchronously +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.withContext +import normalizer.HeroAndFriendsNamesQuery +import normalizer.type.Episode +import java.util.concurrent.Executors +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * These tests are only on the JVM as on native all the cache operations are serialized so it's impossible to read the cache before it + * has been written and confirm/infirm the test. Maybe we could do something with an AtomicReference or something like this + */ +class WriteToCacheAsynchronouslyTest { + private lateinit var mockServer: MockServer + private lateinit var apolloClient: ApolloClient + private lateinit var store: ApolloStore + private var dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + + private suspend fun setUp() { + store = ApolloStore(MemoryCacheFactory()) + mockServer = MockServer() + apolloClient = ApolloClient.Builder() + .serverUrl(mockServer.url()) + .dispatcher(dispatcher) + .store(store) + .build() + } + + private suspend fun tearDown() { + mockServer.close() + dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + } + + /** + * Write to cache asynchronously, make sure records are not in cache when we receive the response + */ + @Test + fun writeToCacheAsynchronously() = runTest({ setUp() }, { tearDown() }) { + withContext(dispatcher) { + val query = HeroAndFriendsNamesQuery(Episode.JEDI) + + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNameResponse.json")) + apolloClient.query(query) + .writeToCacheAsynchronously(true) + .execute() + + + val record = store.accessCache { it.loadRecord(QUERY_ROOT_KEY, CacheHeaders.NONE) } + assertNull(record) + } + } + + /** + * Write to cache synchronously, make sure records are in cache when we receive the response + */ + @Test + fun writeToCacheSynchronously() = runTest({ setUp() }, { tearDown() }) { + withContext(dispatcher) { + val query = HeroAndFriendsNamesQuery(Episode.JEDI) + + mockServer.enqueueString(testFixtureToUtf8("HeroAndFriendsNameResponse.json")) + apolloClient.query(query) + .writeToCacheAsynchronously(false) + .execute() + + val record = store.accessCache { it.loadRecord(QUERY_ROOT_KEY, CacheHeaders.NONE) } + assertNotNull(record) + } + } + + companion object { + const val QUERY_ROOT_KEY = "QUERY_ROOT" + } +} diff --git a/tests/normalized-cache/testFixtures/AllPlanetsListOfObjectWithNullObject.json b/tests/normalized-cache/testFixtures/AllPlanetsListOfObjectWithNullObject.json new file mode 100644 index 00000000..02241807 --- /dev/null +++ b/tests/normalized-cache/testFixtures/AllPlanetsListOfObjectWithNullObject.json @@ -0,0 +1,47 @@ +{ + "data": { + "allPlanets": { + "__typename": "PlanetsConnection", + "planets": [ + { + "__typename": "Planet", + "name": "Tatooine", + "climates": [ + "arid" + ], + "surfaceWater": 1, + "filmConnection": null + }, + { + "__typename": "Planet", + "name": "Alderaan", + "climates": [ + "temperate" + ], + "surfaceWater": 40, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 2, + "films": [ + { + "__typename": "Film", + "title": "A New Hope", + "producers": [ + "Gary Kurtz", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + } + ] + } + } +} \ No newline at end of file diff --git a/tests/normalized-cache/testFixtures/AllPlanetsNullableField.json b/tests/normalized-cache/testFixtures/AllPlanetsNullableField.json new file mode 100644 index 00000000..78c2b1e5 --- /dev/null +++ b/tests/normalized-cache/testFixtures/AllPlanetsNullableField.json @@ -0,0 +1,1073 @@ +{ + "data": { + "allPlanets": { + "__typename": "PlanetsConnection", + "planets": [ + { + "__typename": "Planet", + "name": "Tatooine", + "climates": [ + "arid" + ], + "surfaceWater": 1, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 5, + "films": [ + { + "__typename": "Film", + "title": "A New Hope", + "producers": [ + "Gary Kurtz", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "The Phantom Menace", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Alderaan", + "climates": [ + "temperate" + ], + "surfaceWater": 40, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 2, + "films": [ + { + "__typename": "Film", + "title": "A New Hope", + "producers": [ + "Gary Kurtz", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Yavin IV", + "climates": [ + "temperate", + "tropical" + ], + "surfaceWater": 8, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "A New Hope", + "producers": [ + "Gary Kurtz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Hoth", + "climates": [ + "frozen" + ], + "surfaceWater": 100, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Dagobah", + "climates": [ + "murky" + ], + "surfaceWater": 8, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 3, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Bespin", + "climates": [ + "temperate" + ], + "surfaceWater": 0, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Endor", + "climates": [ + "temperate" + ], + "surfaceWater": 8, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Naboo", + "climates": [ + "temperate" + ], + "surfaceWater": 12, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 4, + "films": [ + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "The Phantom Menace", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Coruscant", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 4, + "films": [ + { + "__typename": "Film", + "title": "Return of the Jedi", + "producers": [ + "Howard G. Kazanjian", + "George Lucas", + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "The Phantom Menace", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + }, + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Kamino", + "climates": [ + "temperate" + ], + "surfaceWater": 100, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Geonosis", + "climates": [ + "temperate", + "arid" + ], + "surfaceWater": 5, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Attack of the Clones", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Utapau", + "climates": [ + "temperate", + "arid", + "windy" + ], + "surfaceWater": 0.9, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Mustafar", + "climates": [ + "hot" + ], + "surfaceWater": 0, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Kashyyyk", + "climates": [ + "tropical" + ], + "surfaceWater": 60, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Polis Massa", + "climates": [ + "artificial temperate" + ], + "surfaceWater": 0, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Mygeeto", + "climates": [ + "frigid" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Felucia", + "climates": [ + "hot", + "humid" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Cato Neimoidia", + "climates": [ + "temperate", + "moist" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Saleucami", + "climates": [ + "hot" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "Revenge of the Sith", + "producers": [ + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "Stewjon", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Eriadu", + "climates": [ + "polluted" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Corellia", + "climates": [ + "temperate" + ], + "surfaceWater": 70, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Rodia", + "climates": [ + "hot" + ], + "surfaceWater": 60, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Nal Hutta", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Dantooine", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Bestine IV", + "climates": [ + "temperate" + ], + "surfaceWater": 98, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Ord Mantell", + "climates": [ + "temperate" + ], + "surfaceWater": 10, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 1, + "films": [ + { + "__typename": "Film", + "title": "The Empire Strikes Back", + "producers": [ + "Gary Kutz", + "Rick McCallum" + ] + } + ] + } + }, + { + "__typename": "Planet", + "name": "unknown", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Trandosha", + "climates": [ + "arid" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Socorro", + "climates": [ + "arid" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Mon Cala", + "climates": [ + "temperate" + ], + "surfaceWater": 100, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Chandrila", + "climates": [ + "temperate" + ], + "surfaceWater": 40, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Sullust", + "climates": [ + "superheated" + ], + "surfaceWater": 5, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Toydaria", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Malastare", + "climates": [ + "arid", + "temperate", + "tropical" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Dathomir", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Ryloth", + "climates": [ + "temperate", + "arid", + "subartic" + ], + "surfaceWater": 5, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Aleen Minor", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Vulpter", + "climates": [ + "temperate", + "artic" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Troiken", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Tund", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Haruun Kal", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Cerea", + "climates": [ + "temperate" + ], + "surfaceWater": 20, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Glee Anselm", + "climates": [ + "tropical", + "temperate" + ], + "surfaceWater": 80, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Iridonia", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Tholoth", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Iktotch", + "climates": [ + "arid", + "rocky", + "windy" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Quermia", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Dorin", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Champala", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Mirial", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Serenno", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Concord Dawn", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Zolan", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Ojom", + "climates": [ + "frigid" + ], + "surfaceWater": 100, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Skako", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Muunilinst", + "climates": [ + "temperate" + ], + "surfaceWater": 25, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Shili", + "climates": [ + "temperate" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Kalee", + "climates": [ + "arid", + "temperate", + "tropical" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + }, + { + "__typename": "Planet", + "name": "Umbara", + "climates": [ + "unknown" + ], + "surfaceWater": null, + "filmConnection": { + "__typename": "PlanetFilmsConnection", + "totalCount": 0, + "films": [] + } + } + ] + } + } +} \ No newline at end of file diff --git a/tests/normalized-cache/testFixtures/EpisodeHeroNameResponse.json b/tests/normalized-cache/testFixtures/EpisodeHeroNameResponse.json new file mode 100644 index 00000000..fbdf24a6 --- /dev/null +++ b/tests/normalized-cache/testFixtures/EpisodeHeroNameResponse.json @@ -0,0 +1,8 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "name": "R2-D2" + } + } +} diff --git a/tests/normalized-cache/testFixtures/EpisodeHeroNameResponseNameChange.json b/tests/normalized-cache/testFixtures/EpisodeHeroNameResponseNameChange.json new file mode 100644 index 00000000..c479f59d --- /dev/null +++ b/tests/normalized-cache/testFixtures/EpisodeHeroNameResponseNameChange.json @@ -0,0 +1,9 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "id": "2001", + "name": "Artoo" + } + } +} diff --git a/tests/normalized-cache/testFixtures/EpisodeHeroNameResponseWithId.json b/tests/normalized-cache/testFixtures/EpisodeHeroNameResponseWithId.json new file mode 100644 index 00000000..e69a0111 --- /dev/null +++ b/tests/normalized-cache/testFixtures/EpisodeHeroNameResponseWithId.json @@ -0,0 +1,9 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "id": "2001", + "name": "R2-D2" + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroAndFriendsNameResponse.json b/tests/normalized-cache/testFixtures/HeroAndFriendsNameResponse.json new file mode 100644 index 00000000..a7822134 --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroAndFriendsNameResponse.json @@ -0,0 +1,22 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "name": "R2-D2", + "friends": [ + { + "__typename": "Human", + "name": "Luke Skywalker" + }, + { + "__typename": "Human", + "name": "Han Solo" + }, + { + "__typename": "Human", + "name": "Leia Organa" + } + ] + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroAndFriendsNameWithIdsParentOnlyResponse.json b/tests/normalized-cache/testFixtures/HeroAndFriendsNameWithIdsParentOnlyResponse.json new file mode 100644 index 00000000..7e510e96 --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroAndFriendsNameWithIdsParentOnlyResponse.json @@ -0,0 +1,23 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friends": [ + { + "__typename": "Human", + "name": "Luke Skywalker" + }, + { + "__typename": "Human", + "name": "Han Solo" + }, + { + "__typename": "Human", + "name": "Leia Organa" + } + ] + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroAndFriendsNameWithIdsResponse.json b/tests/normalized-cache/testFixtures/HeroAndFriendsNameWithIdsResponse.json new file mode 100644 index 00000000..9fa71b22 --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroAndFriendsNameWithIdsResponse.json @@ -0,0 +1,26 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "id": "2001", + "name": "R2-D2", + "friends": [ + { + "__typename": "Human", + "id": "1000", + "name": "Luke Skywalker" + }, + { + "__typename": "Human", + "id": "1002", + "name": "Han Solo" + }, + { + "__typename": "Human", + "id": "1003", + "name": "Leia Organa" + } + ] + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroAppearsInResponse.json b/tests/normalized-cache/testFixtures/HeroAppearsInResponse.json new file mode 100644 index 00000000..ba257ab3 --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroAppearsInResponse.json @@ -0,0 +1,12 @@ +{ + "data": { + "hero": { + "__typename": "Human", + "appearsIn": [ + "NEWHOPE", + "EMPIRE", + "JEDI" + ] + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroAppearsInResponseWithNulls.json b/tests/normalized-cache/testFixtures/HeroAppearsInResponseWithNulls.json new file mode 100644 index 00000000..5934160b --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroAppearsInResponseWithNulls.json @@ -0,0 +1,15 @@ +{ + "data": { + "hero": { + "__typename": "Human", + "appearsIn": [ + null, + "NEWHOPE", + "EMPIRE", + null, + "JEDI", + null + ] + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroNameResponse.json b/tests/normalized-cache/testFixtures/HeroNameResponse.json new file mode 100644 index 00000000..220b90e7 --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroNameResponse.json @@ -0,0 +1,19 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "name": "R2-D2" + } + }, + "extensions": { + "cost": { + "requestedQueryCost": 3, + "actualQueryCost": 3, + "throttleStatus": { + "maximumAvailable": 1000, + "currentlyAvailable": 997, + "restoreRate": 50 + } + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroNameWithIdResponse.json b/tests/normalized-cache/testFixtures/HeroNameWithIdResponse.json new file mode 100644 index 00000000..2c115c71 --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroNameWithIdResponse.json @@ -0,0 +1,9 @@ +{ + "data": { + "hero": { + "__typename": "Human", + "id": "1000", + "name": "SuperMan" + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroParentTypeDependentFieldDroidResponse.json b/tests/normalized-cache/testFixtures/HeroParentTypeDependentFieldDroidResponse.json new file mode 100644 index 00000000..ba414a2f --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroParentTypeDependentFieldDroidResponse.json @@ -0,0 +1,25 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "name": "R2-D2", + "friends": [ + { + "__typename": "Human", + "name": "Luke Skywalker", + "height": 1.72 + }, + { + "__typename": "Human", + "name": "Han Solo", + "height": 1.8 + }, + { + "__typename": "Human", + "name": "Leia Organa", + "height": 1.5 + } + ] + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroParentTypeDependentFieldHumanResponse.json b/tests/normalized-cache/testFixtures/HeroParentTypeDependentFieldHumanResponse.json new file mode 100644 index 00000000..2e224e19 --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroParentTypeDependentFieldHumanResponse.json @@ -0,0 +1,28 @@ +{ + "data": { + "hero": { + "__typename": "Human", + "name": "Luke Skywalker", + "friends": [ + { + "__typename": "Human", + "name": "Han Solo", + "height": 5.905512 + }, + { + "__typename": "Human", + "name": "Leia Organa", + "height": 4.92126 + }, + { + "__typename": "Droid", + "name": "C-3PO" + }, + { + "__typename": "Droid", + "name": "R2-D2" + } + ] + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroTypeDependentAliasedFieldResponse.json b/tests/normalized-cache/testFixtures/HeroTypeDependentAliasedFieldResponse.json new file mode 100644 index 00000000..c4abfde0 --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroTypeDependentAliasedFieldResponse.json @@ -0,0 +1,8 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "property": "Astromech" + } + } +} diff --git a/tests/normalized-cache/testFixtures/HeroTypeDependentAliasedFieldResponseHuman.json b/tests/normalized-cache/testFixtures/HeroTypeDependentAliasedFieldResponseHuman.json new file mode 100644 index 00000000..2690b25e --- /dev/null +++ b/tests/normalized-cache/testFixtures/HeroTypeDependentAliasedFieldResponseHuman.json @@ -0,0 +1,8 @@ +{ + "data": { + "hero": { + "__typename": "Human", + "property": "Tatooine" + } + } +} diff --git a/tests/normalized-cache/testFixtures/JsonScalar.json b/tests/normalized-cache/testFixtures/JsonScalar.json new file mode 100644 index 00000000..58db69bb --- /dev/null +++ b/tests/normalized-cache/testFixtures/JsonScalar.json @@ -0,0 +1,14 @@ +{ + "data": { + "json": { + "obj": { + "key": "value" + }, + "list": [ + 0, + 1, + 2 + ] + } + } +} diff --git a/tests/normalized-cache/testFixtures/JsonScalarModified.json b/tests/normalized-cache/testFixtures/JsonScalarModified.json new file mode 100644 index 00000000..50a42d7d --- /dev/null +++ b/tests/normalized-cache/testFixtures/JsonScalarModified.json @@ -0,0 +1,9 @@ +{ + "data": { + "json": { + "obj": { + "key2": "value2" + } + } + } +} diff --git a/tests/normalized-cache/testFixtures/ReviewsEmpireEpisodeResponse.json b/tests/normalized-cache/testFixtures/ReviewsEmpireEpisodeResponse.json new file mode 100644 index 00000000..bb6c1a37 --- /dev/null +++ b/tests/normalized-cache/testFixtures/ReviewsEmpireEpisodeResponse.json @@ -0,0 +1,24 @@ +{ + "data": { + "reviews": [ + { + "__typename": "Review", + "id": "empireReview1", + "stars": 1, + "commentary": "Boring" + }, + { + "__typename": "Review", + "id": "empireReview2", + "stars": 2, + "commentary": "So-so" + }, + { + "__typename": "Review", + "id": "empireReview3", + "stars": 5, + "commentary": "Amazing" + } + ] + } +} diff --git a/tests/normalized-cache/testFixtures/SameHeroTwiceResponse.json b/tests/normalized-cache/testFixtures/SameHeroTwiceResponse.json new file mode 100644 index 00000000..a20119a2 --- /dev/null +++ b/tests/normalized-cache/testFixtures/SameHeroTwiceResponse.json @@ -0,0 +1,16 @@ +{ + "data": { + "hero": { + "__typename": "Droid", + "name": "R2-D2" + }, + "r2": { + "__typename": "Droid", + "appearsIn": [ + "NEWHOPE", + "EMPIRE", + "JEDI" + ] + } + } +} diff --git a/tests/normalized-cache/testFixtures/StarshipByIdResponse.json b/tests/normalized-cache/testFixtures/StarshipByIdResponse.json new file mode 100644 index 00000000..a2e98fd6 --- /dev/null +++ b/tests/normalized-cache/testFixtures/StarshipByIdResponse.json @@ -0,0 +1,23 @@ +{ + "data": { + "starship": { + "__typename": "Starship", + "id": "Starship1", + "name": "SuperRocket", + "coordinates": [ + [ + 100, + 200 + ], + [ + 300, + 400 + ], + [ + 500, + 600 + ] + ] + } + } +} diff --git a/tests/normalized-cache/testFixtures/UpdateReviewResponse.json b/tests/normalized-cache/testFixtures/UpdateReviewResponse.json new file mode 100644 index 00000000..b4525a43 --- /dev/null +++ b/tests/normalized-cache/testFixtures/UpdateReviewResponse.json @@ -0,0 +1,10 @@ +{ + "data": { + "updateReview": { + "__typename": "Review", + "id": "empireReview2", + "stars": 4, + "commentary": "Not Bad" + } + } +} diff --git a/tests/number-scalar/build.gradle.kts b/tests/number-scalar/build.gradle.kts new file mode 100644 index 00000000..bbefea17 --- /dev/null +++ b/tests/number-scalar/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.apollo) +} + +kotlin { + configureKmp( + withJs = true, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) + + sourceSets { + getByName("commonMain") { + dependencies { + implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + getByName("commonTest") { + dependencies { + implementation(libs.apollo.testing.support) + implementation(libs.kotlin.test) + } + } + } +} + +apollo { + service("service") { + packageName.set("com.example") + mapScalar("Number", "kotlin.String") + } +} diff --git a/tests/number-scalar/src/commonMain/graphql/operation.graphql b/tests/number-scalar/src/commonMain/graphql/operation.graphql new file mode 100644 index 00000000..66e31834 --- /dev/null +++ b/tests/number-scalar/src/commonMain/graphql/operation.graphql @@ -0,0 +1,3 @@ +query GetNumber { + number +} \ No newline at end of file diff --git a/tests/number-scalar/src/commonMain/graphql/schema.graphqls b/tests/number-scalar/src/commonMain/graphql/schema.graphqls new file mode 100644 index 00000000..7cdd0725 --- /dev/null +++ b/tests/number-scalar/src/commonMain/graphql/schema.graphqls @@ -0,0 +1,5 @@ +scalar Number + +type Query { + number: Number +} \ No newline at end of file diff --git a/tests/number-scalar/src/commonTest/kotlin/test/NumberTest.kt b/tests/number-scalar/src/commonTest/kotlin/test/NumberTest.kt new file mode 100644 index 00000000..a4bd8dc1 --- /dev/null +++ b/tests/number-scalar/src/commonTest/kotlin/test/NumberTest.kt @@ -0,0 +1,49 @@ +package test + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.Adapter +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.json.JsonNumber +import com.apollographql.apollo.api.json.JsonReader +import com.apollographql.apollo.api.json.JsonWriter +import com.apollographql.apollo.testing.QueueTestNetworkTransport +import com.apollographql.apollo.testing.enqueueTestResponse +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.fetchPolicy +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import com.example.GetNumberQuery +import okio.use +import kotlin.test.Test +import kotlin.test.assertEquals + +class NumberTest { + @Test + fun test() = runTest { + ApolloClient.Builder() + .networkTransport(QueueTestNetworkTransport()) + .normalizedCache(MemoryCacheFactory()) + .addCustomScalarAdapter(com.example.type.Number.type, NumberAdapter()) + .build() + .use { apolloClient -> + apolloClient.enqueueTestResponse(GetNumberQuery(), GetNumberQuery.Data("12345")) + apolloClient.query(GetNumberQuery()).fetchPolicy(FetchPolicy.NetworkOnly).execute().apply { + assertEquals("12345", data?.number) + } + apolloClient.query(GetNumberQuery()).fetchPolicy(FetchPolicy.CacheOnly).execute().apply { + assertEquals("12345", data?.number) + } + } + } +} + +class NumberAdapter : Adapter { + override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): String { + return reader.nextNumber().value + } + + override fun toJson(writer: JsonWriter, customScalarAdapters: CustomScalarAdapters, value: String) { + writer.value(JsonNumber(value)) + } +} diff --git a/tests/optimistic-data/build.gradle.kts b/tests/optimistic-data/build.gradle.kts new file mode 100644 index 00000000..6934a6fc --- /dev/null +++ b/tests/optimistic-data/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.apollo) +} + +kotlin { + configureKmp( + withJs = true, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) + + sourceSets { + getByName("commonMain") { + dependencies { + implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + getByName("commonTest") { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.apollo.testing.support) + implementation(libs.apollo.mockserver) + implementation(libs.turbine) + } + } + } +} + +apollo { + service("service") { + packageName.set("optimistic") + } +} diff --git a/tests/optimistic-data/src/commonMain/graphql/operation.graphql b/tests/optimistic-data/src/commonMain/graphql/operation.graphql new file mode 100644 index 00000000..120f8f03 --- /dev/null +++ b/tests/optimistic-data/src/commonMain/graphql/operation.graphql @@ -0,0 +1,28 @@ +query GetAnimal($id: String!) { + animal(id: $id) { + id + name + species + } +} + +mutation UpdateAnimalName($input: AnimalInput!) { + updateAnimal(input: $input) { + success + animal { + id + name + } + } +} + +mutation UpdateAnimalSpecies($input: AnimalInput!) { + updateAnimal(input: $input) { + success + animal { + id + species + } + } +} + diff --git a/tests/optimistic-data/src/commonMain/graphql/schema.graphqls b/tests/optimistic-data/src/commonMain/graphql/schema.graphqls new file mode 100644 index 00000000..80c98b1e --- /dev/null +++ b/tests/optimistic-data/src/commonMain/graphql/schema.graphqls @@ -0,0 +1,24 @@ +type Query { + animal(id: String!): Animal! +} + +type Mutation { + updateAnimal(input: AnimalInput!): AnimalResult! +} + +type Animal @typePolicy(keyFields: "id") { + id: String! + name: String + species: String +} + +type AnimalResult { + success: Boolean! + animal: Animal +} + +input AnimalInput { + id: String! + name: String + species: String +} \ No newline at end of file diff --git a/tests/optimistic-data/src/jvmTest/kotlin/test/OptimisticDataTest.kt b/tests/optimistic-data/src/jvmTest/kotlin/test/OptimisticDataTest.kt new file mode 100644 index 00000000..3381d95a --- /dev/null +++ b/tests/optimistic-data/src/jvmTest/kotlin/test/OptimisticDataTest.kt @@ -0,0 +1,123 @@ +package test + +import app.cash.turbine.test +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.memory.MemoryCacheFactory +import com.apollographql.cache.normalized.normalizedCache +import com.apollographql.cache.normalized.optimisticUpdates +import com.apollographql.cache.normalized.watch +import com.apollographql.mockserver.MockResponse +import com.apollographql.mockserver.MockServer +import com.apollographql.mockserver.enqueueString +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import optimistic.GetAnimalQuery +import optimistic.UpdateAnimalNameMutation +import optimistic.UpdateAnimalSpeciesMutation +import optimistic.type.AnimalInput +import kotlin.test.Test +import kotlin.test.assertEquals + +class OptimisticDataTest { + private fun optimisticNameData(name: String) = UpdateAnimalNameMutation.Data( + updateAnimal = UpdateAnimalNameMutation.UpdateAnimal( + success = true, + animal = UpdateAnimalNameMutation.Animal( + id = "1", + __typename = "Cat", + name = name + ) + ) + ) + + private fun optimisticSpeciesData(species: String) = UpdateAnimalSpeciesMutation.Data( + updateAnimal = UpdateAnimalSpeciesMutation.UpdateAnimal( + success = true, + animal = UpdateAnimalSpeciesMutation.Animal( + id = "1", + __typename = "Cat", + species = species + ) + ) + ) + + @Test + fun canRevertAnIntermediateData() = runTest { + val server = MockServer() + val apolloClient = ApolloClient.Builder() + .serverUrl(server.url()) + .normalizedCache(MemoryCacheFactory()) + .build() + + server.enqueueString(""" + { + "data": { + "animal": { + "__typename": "Cat", + "id": "1", + "name": "Noushka", + "species": "Cat" + } + } + } + """.trimIndent() + ) + + apolloClient.query(GetAnimalQuery("1")) + .watch() + .map { it.data?.animal } + .filterNotNull() + .test { + awaitItem().let { + // t + 0: First item from the initial response + assertEquals("Noushka", it.name) + assertEquals("Cat", it.species) + } + + server.enqueue(MockResponse.Builder().statusCode(500).delayMillis(400).build()) + launch { + val mutation = UpdateAnimalNameMutation(AnimalInput("Irrelevant")) + try { + apolloClient.mutation(mutation) + .optimisticUpdates(optimisticNameData("Medor")) + .execute() + } catch (e: Exception) { + // Ignore + } + } + + server.enqueue(MockResponse.Builder().statusCode(501).delayMillis(10_000).build()) + val job = launch { + delay(200) + val mutation = UpdateAnimalSpeciesMutation(AnimalInput("Irrelevant")) + apolloClient.mutation(mutation) + .optimisticUpdates(optimisticSpeciesData("Dog")) + .execute() + } + + awaitItem().let { + // t + 0: Optimistic name change + assertEquals("Medor", it.name) + assertEquals("Cat", it.species) + } + awaitItem().let { + // t + 200ms: Optimistic species change + assertEquals("Medor", it.name) + assertEquals("Dog", it.species) + } + awaitItem().let { + // t + 400ms: Failure, rollback name change + assertEquals("Noushka", it.name) + assertEquals("Dog", it.species) + } + + // No need to wait for the 10s job to finish + job.cancel() + + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/tests/pagination/build.gradle.kts b/tests/pagination/build.gradle.kts index 9d1a8da1..f6838eee 100644 --- a/tests/pagination/build.gradle.kts +++ b/tests/pagination/build.gradle.kts @@ -1,5 +1,4 @@ import com.apollographql.apollo.annotations.ApolloExperimental -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi plugins { alias(libs.plugins.kotlin.multiplatform) @@ -7,37 +6,18 @@ plugins { } kotlin { - jvm() - macosX64() - macosArm64() - iosArm64() - iosX64() - iosSimulatorArm64() - watchosArm32() - watchosArm64() - watchosSimulatorArm64() - tvosArm64() - tvosX64() - tvosSimulatorArm64() - - @OptIn(ExperimentalKotlinGradlePluginApi::class) - applyDefaultHierarchyTemplate { - group("common") { - group("concurrent") { - group("native") { - group("apple") - } - group("jvmCommon") { - withJvm() - } - } - } - } + configureKmp( + withJs = false, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) sourceSets { getByName("commonMain") { dependencies { implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-sqlite-incubating") } } @@ -46,7 +26,6 @@ kotlin { implementation(libs.apollo.testing.support) implementation(libs.apollo.mockserver) implementation(libs.kotlin.test) - implementation("com.apollographql.cache:normalized-cache-sqlite-incubating") } } @@ -55,11 +34,6 @@ kotlin { implementation(libs.slf4j.nop) } } - - configureEach { - languageSettings.optIn("com.apollographql.apollo.annotations.ApolloExperimental") - languageSettings.optIn("com.apollographql.apollo.annotations.ApolloInternal") - } } } diff --git a/tests/schema-changes/build.gradle.kts b/tests/schema-changes/build.gradle.kts new file mode 100644 index 00000000..c9c9ace6 --- /dev/null +++ b/tests/schema-changes/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.apollo) +} + +kotlin { + configureKmp( + withJs = true, + withWasm = false, + withAndroid = false, + withApple = AppleTargets.Host, + ) + + sourceSets { + getByName("commonMain") { + dependencies { + implementation(libs.apollo.runtime) + implementation("com.apollographql.cache:normalized-cache-incubating") + } + } + + getByName("commonTest") { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.apollo.testing.support) + implementation(libs.apollo.mockserver) + implementation(libs.turbine) + } + } + } +} + +apollo { + service("service") { + packageName.set("schema.changes") + codegenModels.set("responseBased") + } +} diff --git a/tests/schema-changes/src/commonMain/graphql/operation.graphql b/tests/schema-changes/src/commonMain/graphql/operation.graphql new file mode 100644 index 00000000..b635bd41 --- /dev/null +++ b/tests/schema-changes/src/commonMain/graphql/operation.graphql @@ -0,0 +1,9 @@ +query GetField { + field { + ... on Field { + id + name + } + } +} + diff --git a/tests/schema-changes/src/commonMain/graphql/schema.graphqls b/tests/schema-changes/src/commonMain/graphql/schema.graphqls new file mode 100644 index 00000000..040477f9 --- /dev/null +++ b/tests/schema-changes/src/commonMain/graphql/schema.graphqls @@ -0,0 +1,16 @@ +type Query { + field: Field! +} + +interface Field { + id: String + name: String +} + +type DefaultField implements Field { + id: String + name: String +} + + + diff --git a/tests/schema-changes/src/commonTest/kotlin/test/SchemaChanges.kt b/tests/schema-changes/src/commonTest/kotlin/test/SchemaChanges.kt new file mode 100644 index 00000000..773971f3 --- /dev/null +++ b/tests/schema-changes/src/commonTest/kotlin/test/SchemaChanges.kt @@ -0,0 +1,49 @@ +package test + +import com.apollographql.apollo.api.CustomScalarAdapters +import com.apollographql.apollo.api.json.jsonReader +import com.apollographql.apollo.testing.internal.runTest +import com.apollographql.cache.normalized.api.TypePolicyCacheKeyGenerator +import com.apollographql.cache.normalized.api.normalize +import okio.Buffer +import schema.changes.GetFieldQuery +import kotlin.test.Test + +class SchemaChangesTest { + @Test + fun schemaChanges() = runTest { + val operation = GetFieldQuery() + + @Suppress("UNUSED_VARIABLE") + val v1Data = """ + { + "field": { + "__typename": "DefaultField", + "id": "1", + "name": "Name1" + } + } + """.trimIndent() + + val v2Data = """ + { + "field": { + "__typename": "NewField", + "id": "1", + "name": "Name1" + } + } + """.trimIndent() + + val data = operation.adapter().fromJson( + Buffer().writeUtf8(v2Data).jsonReader(), + CustomScalarAdapters.Empty + ) + + operation.normalize( + data, + customScalarAdapters = CustomScalarAdapters.Empty, + cacheKeyGenerator = TypePolicyCacheKeyGenerator, + ) + } +} diff --git a/tests/settings.gradle.kts b/tests/settings.gradle.kts index 270910de..2677dcf7 100644 --- a/tests/settings.gradle.kts +++ b/tests/settings.gradle.kts @@ -1,18 +1,13 @@ includeBuild("../") pluginManagement { - listOf(repositories, dependencyResolutionManagement.repositories).forEach { - it.apply { - maven { - url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") - } - mavenCentral() - } - } + includeBuild("../build-logic") } rootProject.name = "apollo-kotlin-normalized-cache-incubating-tests" +apply(from = "../gradle/repositories.gradle.kts") + // Include all tests rootProject.projectDir .listFiles()!!