From 8f185204edab456710911c0c5a04e570ed9e8fd2 Mon Sep 17 00:00:00 2001 From: Aileen Chen Date: Thu, 16 Oct 2025 19:14:40 +0000 Subject: [PATCH] Fix bug with lazy EODs resolving twice If the client query includes a node, and a resolver RSS also includes the same node in the same OER (same path from the root query), we attempt to call `resolveData` on the lazyeod twice, which ends up calling the node resolver twice. This also results in OER.resolve() throwing since it's already been resolved. This fixes this bug, and also migrates NodeResolverTest from the old to the new engine feature test framework so I can add a test case there. Github-Change-Id: 951240 GitOrigin-RevId: ac6e62c630c899e7468bfca9f8069252d9bbb12f --- .../engine/api/LazyEngineObjectData.kt | 7 +- .../kotlin/viaduct/engine/api/mocks/Mocks.kt | 2 +- .../runtime/NodeEngineObjectDataImpl.kt | 11 +- .../engine/runtime/ObjectEngineResultImpl.kt | 21 +- .../runtime/NodeEngineObjectDataImplTest.kt | 19 + .../engine/runtime/NodeResolverTest.kt | 364 ++++++++++++++++++ .../api/mocks/ExecutionContextMocks.kt | 2 +- .../runtime/featuretests/NodeResolverTest.kt | 221 ----------- 8 files changed, 416 insertions(+), 231 deletions(-) create mode 100644 engine/runtime/src/test/kotlin/viaduct/engine/runtime/NodeResolverTest.kt delete mode 100644 tenant/runtime/src/integrationTest/kotlin/viaduct/tenant/runtime/featuretests/NodeResolverTest.kt diff --git a/engine/api/src/main/kotlin/viaduct/engine/api/LazyEngineObjectData.kt b/engine/api/src/main/kotlin/viaduct/engine/api/LazyEngineObjectData.kt index 5d21a8c3..fea5d8e5 100644 --- a/engine/api/src/main/kotlin/viaduct/engine/api/LazyEngineObjectData.kt +++ b/engine/api/src/main/kotlin/viaduct/engine/api/LazyEngineObjectData.kt @@ -4,8 +4,13 @@ package viaduct.engine.api * An EngineObjectData that is not fully resolved until [resolveData] is called. */ interface LazyEngineObjectData : EngineObjectData { + /** + * Resolves the data for this lazy object. + * + * @return true if the data was resolved by this call, false if it was already called previously + */ suspend fun resolveData( selections: RawSelectionSet, context: EngineExecutionContext - ) + ): Boolean } diff --git a/engine/api/src/testFixtures/kotlin/viaduct/engine/api/mocks/Mocks.kt b/engine/api/src/testFixtures/kotlin/viaduct/engine/api/mocks/Mocks.kt index b68c9bfa..0f817da3 100644 --- a/engine/api/src/testFixtures/kotlin/viaduct/engine/api/mocks/Mocks.kt +++ b/engine/api/src/testFixtures/kotlin/viaduct/engine/api/mocks/Mocks.kt @@ -56,7 +56,7 @@ import viaduct.graphql.utils.DefaultSchemaProvider typealias CheckerFn = suspend (arguments: Map, objectDataMap: Map) -> Unit typealias NodeBatchResolverFn = suspend (selectors: List, context: EngineExecutionContext) -> Map> -typealias NodeUnbatchedResolverFn = (id: String, selections: RawSelectionSet?, context: EngineExecutionContext) -> EngineObjectData +typealias NodeUnbatchedResolverFn = suspend (id: String, selections: RawSelectionSet?, context: EngineExecutionContext) -> EngineObjectData typealias FieldUnbatchedResolverFn = suspend ( arguments: Map, objectValue: EngineObjectData, diff --git a/engine/runtime/src/main/kotlin/viaduct/engine/runtime/NodeEngineObjectDataImpl.kt b/engine/runtime/src/main/kotlin/viaduct/engine/runtime/NodeEngineObjectDataImpl.kt index 1c2c2fbe..65c462d4 100644 --- a/engine/runtime/src/main/kotlin/viaduct/engine/runtime/NodeEngineObjectDataImpl.kt +++ b/engine/runtime/src/main/kotlin/viaduct/engine/runtime/NodeEngineObjectDataImpl.kt @@ -1,6 +1,7 @@ package viaduct.engine.runtime import graphql.schema.GraphQLObjectType +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.supervisorScope @@ -20,6 +21,7 @@ class NodeEngineObjectDataImpl( ) : NodeEngineObjectData, NodeReference { private lateinit var resolvedEngineObjectData: EngineObjectData private val resolving = CompletableDeferred() + private val resolveDataCalled = AtomicBoolean(false) override suspend fun fetch(selection: String): Any? = idOrWait(selection) ?: resolvedEngineObjectData.fetch(selection) @@ -40,11 +42,17 @@ class NodeEngineObjectDataImpl( /** * To be called by the engine to resolve this node reference. + * + * @return true if the data was resolved by this call, false if it was already called previously */ override suspend fun resolveData( selections: RawSelectionSet, context: EngineExecutionContext - ) { + ): Boolean { + if (!resolveDataCalled.compareAndSet(false, true)) { + return false + } + try { val nodeResolver = resolverRegistry.getNodeResolverDispatcher(graphQLObjectType.name) ?: throw IllegalStateException("No node resolver found for type ${graphQLObjectType.name}") @@ -82,6 +90,7 @@ class NodeEngineObjectDataImpl( resolvedEngineObjectData = nodeResolver.resolve(id, selections, context) resolving.complete(Unit) } + return true } catch (e: Exception) { resolving.completeExceptionally(e) throw e diff --git a/engine/runtime/src/main/kotlin/viaduct/engine/runtime/ObjectEngineResultImpl.kt b/engine/runtime/src/main/kotlin/viaduct/engine/runtime/ObjectEngineResultImpl.kt index 1494e9d2..3ce4eef5 100644 --- a/engine/runtime/src/main/kotlin/viaduct/engine/runtime/ObjectEngineResultImpl.kt +++ b/engine/runtime/src/main/kotlin/viaduct/engine/runtime/ObjectEngineResultImpl.kt @@ -152,24 +152,33 @@ class ObjectEngineResultImpl private constructor( private fun maybeInitializeKey(key: ObjectEngineResult.Key): Cell = storage.computeIfAbsent(key) { newCell() } /** - * Resolves this OER exceptionally if it is in the pending state + * Resolves this OER exceptionally if it is in the pending state. + * If already resolved with the same exception, this is a no-op. * - * @throws IllegalStateException if this OER is already resolved + * @throws IllegalStateException if this OER is already resolved normally or with a different exception */ internal fun resolveExceptionally(exception: Throwable) { if (!oerState.completeExceptionally(exception)) { - throw IllegalStateException("Invariant: already resolved") + val completionException = oerState.getCompletionExceptionOrNull() + if (completionException == exception) { + return + } + // Otherwise it was resolved normally or with a different exception + throw IllegalStateException("Invariant: already resolved", completionException) } } /** - * Resolves this OER if it is in the pending state + * Resolves this OER if it is in the pending state. + * If already resolved normally, this is a no-op. * - * @throws IllegalStateException if this OER is already resolved + * @throws IllegalStateException if this OER is already resolved exceptionally */ internal fun resolve() { if (!oerState.complete(Unit)) { - throw IllegalStateException("Invariant: already resolved") + oerState.getCompletionExceptionOrNull()?.let { + throw IllegalStateException("Invariant: already resolved exceptionally", it) + } } } diff --git a/engine/runtime/src/test/kotlin/viaduct/engine/runtime/NodeEngineObjectDataImplTest.kt b/engine/runtime/src/test/kotlin/viaduct/engine/runtime/NodeEngineObjectDataImplTest.kt index c98b3776..7d3dca2d 100644 --- a/engine/runtime/src/test/kotlin/viaduct/engine/runtime/NodeEngineObjectDataImplTest.kt +++ b/engine/runtime/src/test/kotlin/viaduct/engine/runtime/NodeEngineObjectDataImplTest.kt @@ -3,6 +3,7 @@ package viaduct.engine.runtime import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -117,4 +118,22 @@ class NodeEngineObjectDataImplTest { nodeReference.fetch("foo") } } + + @Test + fun `resolveData called twice`(): Unit = + runBlocking { + every { dispatcherRegistry.getNodeResolverDispatcher("TestType") }.returns(nodeResolver) + coEvery { nodeResolver.resolve("testID", selections, context) }.returns(engineObjectData) + coEvery { engineObjectData.fetch("name") }.returns("testName") + every { dispatcherRegistry.getTypeCheckerDispatcher("TestType") }.returns(null) + + val result1 = nodeReference.resolveData(selections, context) + assertEquals(true, result1) + + val result2 = nodeReference.resolveData(selections, context) + assertEquals(false, result2) + coVerify(exactly = 1) { nodeResolver.resolve("testID", selections, context) } + + assertEquals("testName", nodeReference.fetch("name")) + } } diff --git a/engine/runtime/src/test/kotlin/viaduct/engine/runtime/NodeResolverTest.kt b/engine/runtime/src/test/kotlin/viaduct/engine/runtime/NodeResolverTest.kt new file mode 100644 index 00000000..d3789798 --- /dev/null +++ b/engine/runtime/src/test/kotlin/viaduct/engine/runtime/NodeResolverTest.kt @@ -0,0 +1,364 @@ +package viaduct.engine.runtime + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import viaduct.engine.api.EngineObjectData +import viaduct.engine.api.mocks.MockTenantModuleBootstrapper +import viaduct.engine.api.mocks.mkEngineObjectData +import viaduct.engine.api.mocks.runFeatureTest + +@ExperimentalCoroutinesApi +class NodeResolverTest { + companion object { + private val schemaSDL = """ + extend type Query { + baz: Baz + bazList: [Baz]! + } + type Baz implements Node { + id: ID! + x: Int + x2: String + y: String + z: Int + anotherBaz: Baz + } + """.trimIndent() + } + + @Test + fun `node resolver returns value`() { + MockTenantModuleBootstrapper(schemaSDL) { + field("Query" to "baz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("1", schema.schema.getObjectType("Baz")) + } + } + } + type("Baz") { + nodeUnbatchedExecutor { _, _, _ -> + mkEngineObjectData( + objectType, + mapOf("x" to 42) + ) + } + } + }.runFeatureTest { + viaduct.runQuery("{baz {x}}") + .assertJson("""{"data": {"baz": {"x": 42}}}""") + } + } + + @Test + fun `node resolver is invoked for id-only resolution`() { + var invoked = false + MockTenantModuleBootstrapper(schemaSDL) { + field("Query" to "baz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("1", schema.schema.getObjectType("Baz")) + } + } + } + type("Baz") { + nodeUnbatchedExecutor { _, _, _ -> + invoked = true + mkEngineObjectData(objectType, mapOf()) + } + } + }.runFeatureTest { + viaduct.runQuery("{ baz { id } }") + assertTrue(invoked) + } + } + + @Test + fun `node resolver throws`() { + MockTenantModuleBootstrapper(schemaSDL) { + field("Query" to "baz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("1", schema.schema.getObjectType("Baz")) + } + } + } + type("Baz") { + nodeUnbatchedExecutor { _, _, _ -> + throw RuntimeException("msg") + } + } + }.runFeatureTest { + val result = viaduct.runQuery("{ baz { x } }") + assertEquals(mapOf("baz" to null), result.getData()) + assertTrue(result.errors.any { it.path == listOf("baz") }) + } + } + + @Test + fun `node field executes in parallel with node resolver`() { + var yInvoked = false + MockTenantModuleBootstrapper(schemaSDL) { + field("Query" to "baz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("1", schema.schema.getObjectType("Baz")) + } + } + } + field("Baz" to "y") { + resolver { + fn { _, _, _, _, _ -> + yInvoked = true + "a" + } + } + } + type("Baz") { + nodeUnbatchedExecutor { _, _, _ -> + delay(50) + throw RuntimeException("msg") + } + } + }.runFeatureTest { + val result = viaduct.runQuery("{ baz { y } }") + assertTrue(yInvoked) + assertEquals(mapOf("baz" to null), result.getData()) + assertTrue(result.errors.size == 1) + assertEquals(listOf(listOf("baz")), result.errors.map { it.path }) + } + } + + @Test + fun `awaits completion for node in required selection set`() { + MockTenantModuleBootstrapper(schemaSDL) { + field("Query" to "baz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("1", schema.schema.getObjectType("Baz")) + } + } + } + type("Baz") { + nodeUnbatchedExecutor { id, _, _ -> + if (id == "2") { + delay(50) + throw RuntimeException("expected err") + } else { + mkEngineObjectData(objectType, mapOf("x" to 1)) + } + } + } + field("Baz" to "anotherBaz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("2", schema.schema.getObjectType("Baz")) + } + } + } + field("Baz" to "z") { + resolver { + objectSelections("anotherBaz { id }") + fn { _, objectValue, _, _, _ -> + // The point of this test is that this should wait the node resolver for + // `anotherBaz` to execute rather than immediately returning what's available, + // as we do when the required selection set is on the node itself. Since + // `anotherBaz` will resolve with an exception, this `fetch` call should throw. + objectValue.fetch("anotherBaz") + 5 + } + } + } + }.runFeatureTest { + val result = viaduct.runQuery("{ baz { z } }") + assertEquals(mapOf("baz" to mapOf("z" to null)), result.getData()) + assertEquals(listOf(listOf("baz", "z")), result.errors.map { it.path }) + assertTrue(result.errors[0].message.contains("expected err")) + } + } + + @Test + fun `list of nodes`() { + MockTenantModuleBootstrapper(schemaSDL) { + field("Query" to "bazList") { + resolver { + fn { _, _, _, _, ctx -> + (1..5).map { + ctx.createNodeReference(it.toString(), schema.schema.getObjectType("Baz")) + } + } + } + } + type("Baz") { + nodeUnbatchedExecutor { id, _, _ -> + val internalId = id.toInt() + if (internalId % 2 == 0) { + throw RuntimeException("msg") + } else { + mkEngineObjectData(objectType, mapOf("x" to internalId)) + } + } + } + }.runFeatureTest { + val result = viaduct.runQuery("{ bazList { x } }") + val expectedResultData = mapOf( + "bazList" to listOf( + mapOf("x" to 1), + null, + mapOf("x" to 3), + null, + mapOf("x" to 5), + ), + ) + assertEquals(expectedResultData, result.getData()) + assertEquals(listOf(listOf("bazList", 1), listOf("bazList", 3)), result.errors.map { it.path }) + } + } + + @Test + fun `node resolver does not batch`() { + val execCounts = ConcurrentHashMap() + MockTenantModuleBootstrapper(schemaSDL) { + field("Query" to "bazList") { + resolver { + fn { _, _, _, _, ctx -> + (1..5).map { + ctx.createNodeReference(it.toString(), schema.schema.getObjectType("Baz")) + } + } + } + } + type("Baz") { + nodeUnbatchedExecutor { id, _, _ -> + val internalId = id + execCounts.computeIfAbsent(internalId) { AtomicInteger(0) }.incrementAndGet() + mkEngineObjectData(objectType, mapOf("x" to internalId.toInt())) + } + } + }.runFeatureTest { + val result = viaduct.runQuery("{bazList { x }}") + + // Verify each node was resolved individually (not batched) + assertEquals(mapOf("1" to 1, "2" to 1, "3" to 1, "4" to 1, "5" to 1), execCounts.mapValues { it.value.get() }) + + // Verify the results are correct + val expectedData = mapOf( + "bazList" to listOf( + mapOf("x" to 1), + mapOf("x" to 2), + mapOf("x" to 3), + mapOf("x" to 4), + mapOf("x" to 5) + ) + ) + assertEquals(expectedData, result.getData()) + } + } + + @Test + @Disabled("flaky") + fun `node resolver reads from dataloader cache`() { + val execCounts = ConcurrentHashMap() + MockTenantModuleBootstrapper(schemaSDL) { + field("Query" to "baz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("1", schema.schema.getObjectType("Baz")) + } + } + } + field("Baz" to "anotherBaz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("1", schema.schema.getObjectType("Baz")) + } + } + } + type("Baz") { + nodeUnbatchedExecutor { id, _, _ -> + val internalId = id + execCounts.computeIfAbsent(internalId) { AtomicInteger(0) }.incrementAndGet() + mkEngineObjectData(objectType, mapOf("x" to 2)) + } + } + }.runFeatureTest { + viaduct.runQuery("{ baz { x anotherBaz { id x }}}") + .assertJson("""{"data": {"baz": {"x":2, "anotherBaz":{"id":"1", "x":2}}}}""") + + assertEquals(mapOf("1" to 1), execCounts.mapValues { it.value.get() }) + } + } + + @Test + fun `node resolver does not read from dataloader cache if selection set does not cover`() { + val execCounts = ConcurrentHashMap() + MockTenantModuleBootstrapper(schemaSDL) { + field("Query" to "baz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("1", schema.schema.getObjectType("Baz")) + } + } + } + field("Baz" to "anotherBaz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("1", schema.schema.getObjectType("Baz")) + } + } + } + type("Baz") { + nodeUnbatchedExecutor { id, _, _ -> + execCounts.computeIfAbsent(id) { AtomicInteger(0) }.incrementAndGet() + mkEngineObjectData(objectType, mapOf("x" to 2, "x2" to "foo")) + } + } + }.runFeatureTest { + viaduct.runQuery("{ baz { x anotherBaz { x x2 }}}") + .assertJson("""{"data": {"baz": {"x":2, "anotherBaz":{"x":2, "x2":"foo"}}}}""") + + assertEquals(mapOf("1" to 2), execCounts.mapValues { it.value.get() }) + } + } + + @Test + fun `node resolver not executed twice for the same query path`() { + // This is a regression test for NodeEngineObjectDataImpl.resolveData() calling the + // underlying node resolver each time it's called when it should only call it once. + val execCount = AtomicInteger(0) + MockTenantModuleBootstrapper(schemaSDL) { + field("Query" to "baz") { + resolver { + fn { _, _, _, _, ctx -> + ctx.createNodeReference("1", schema.schema.getObjectType("Baz")) + } + } + } + type("Baz") { + nodeUnbatchedExecutor { id, _, _ -> + execCount.incrementAndGet() + mkEngineObjectData(objectType, mapOf("x" to 10)) + } + } + field("Baz" to "x2") { + resolver { + querySelections("baz { x }") + fn { _, _, queryValue, _, ctx -> + (queryValue.fetch("baz") as EngineObjectData).fetch("x") + } + } + } + }.runFeatureTest { + viaduct.runQuery("{ baz { x x2 }}") + .assertJson("""{"data": {"baz": {"x":10, "x2":"10"}}}""") + + assertEquals(1, execCount.get()) + } + } +} diff --git a/tenant/api/src/testFixtures/kotlin/viaduct/api/mocks/ExecutionContextMocks.kt b/tenant/api/src/testFixtures/kotlin/viaduct/api/mocks/ExecutionContextMocks.kt index 9010ece1..4ee15ae0 100644 --- a/tenant/api/src/testFixtures/kotlin/viaduct/api/mocks/ExecutionContextMocks.kt +++ b/tenant/api/src/testFixtures/kotlin/viaduct/api/mocks/ExecutionContextMocks.kt @@ -60,7 +60,7 @@ class MockNodeEngineObjectData( override suspend fun resolveData( selections: RawSelectionSet, context: EngineExecutionContext - ) { + ): Boolean { throw UnsupportedOperationException() } } diff --git a/tenant/runtime/src/integrationTest/kotlin/viaduct/tenant/runtime/featuretests/NodeResolverTest.kt b/tenant/runtime/src/integrationTest/kotlin/viaduct/tenant/runtime/featuretests/NodeResolverTest.kt deleted file mode 100644 index 7e385ee7..00000000 --- a/tenant/runtime/src/integrationTest/kotlin/viaduct/tenant/runtime/featuretests/NodeResolverTest.kt +++ /dev/null @@ -1,221 +0,0 @@ -package viaduct.tenant.runtime.featuretests - -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Test -import viaduct.api.context.NodeExecutionContext -import viaduct.tenant.runtime.featuretests.fixtures.Baz -import viaduct.tenant.runtime.featuretests.fixtures.FeatureTestBuilder -import viaduct.tenant.runtime.featuretests.fixtures.FeatureTestSchemaFixture -import viaduct.tenant.runtime.featuretests.fixtures.Query -import viaduct.tenant.runtime.featuretests.fixtures.UntypedFieldContext -import viaduct.tenant.runtime.featuretests.fixtures.assertJson - -@ExperimentalCoroutinesApi -class NodeResolverTest { - private fun builder(resolveBaz: suspend (ctx: NodeExecutionContext) -> Baz): FeatureTestBuilder = - FeatureTestBuilder() - .grtPackage(Query.Reflection) - .sdl(FeatureTestSchemaFixture.sdl) - .resolver("Query" to "baz") { ctx -> - ctx.nodeFor(ctx.globalIDFor(Baz.Reflection, "1")) - } - .resolver("Query" to "bazList") { ctx -> - (1..5).map { - ctx.nodeFor(ctx.globalIDFor(Baz.Reflection, it.toString())) - } - } - .nodeResolver(Baz.Reflection.name, resolveBaz) - - @Test - fun `node resolver returns value`() { - builder { ctx -> Baz.Builder(ctx).x(42).build() } - .build() - .assertJson( - "{data: {baz: {x: 42}}}", - "{baz {x}}", - ) - } - - @Test - fun `node resolver is invoked for id-only resolution`() { - var invoked = false - builder { ctx -> Baz.Builder(ctx).build().also { invoked = true } } - .build() - .execute("{ baz { id } }") - .let { _ -> - assertTrue(invoked) - } - } - - @Test - fun `node resolver throws`() { - builder { _ -> throw RuntimeException("msg") } - .build() - .execute("{ baz { x } }") - .let { result -> - assertEquals(mapOf("baz" to null), result.toSpecification()["data"]) - assertTrue(result.errors.any { it.path == listOf("baz") }) { - result.toSpecification().toString() - } - } - } - - @Test - fun `node field executes in parallel with node resolver`() { - var yInvoked = false - val result = builder { _ -> - delay(50) - throw RuntimeException("msg") - } - .resolver( - "Baz" to "y", - { _: UntypedFieldContext -> - yInvoked = true - "a" - } - ) - .build() - .execute("{ baz { y } }") - - assertTrue(yInvoked) - assertEquals(mapOf("baz" to null), result.toSpecification()["data"]) - assertTrue(result.errors.size == 1) - assertEquals(listOf(listOf("baz")), result.errors.map { it.path }) - } - - @Test - fun `awaits completion for node in required selection set`() { - val result = builder { ctx -> - if (ctx.id.internalID == "2") { - delay(50) - throw RuntimeException("msg") - } else { - Baz.Builder(ctx).x(1).build() - } - } - .resolver( - "Baz" to "anotherBaz", - { ctx: UntypedFieldContext -> ctx.nodeFor(ctx.globalIDFor(Baz.Reflection, "2")) } - ) - .resolver( - "Baz" to "z", - { ctx: UntypedFieldContext -> - // The point of this test is that this should wait the node resolver for - // `anotherBaz` to execute rather than immediately returning what's available, - // as we do when the required selection set is on the node itself. Since - // `anotherBaz` will resolve with an exception, this `get` call should throw. - ctx.objectValue.get("anotherBaz") - 5 - }, - "anotherBaz { id }" - ) - .build() - .execute("{ baz { z } }") - - assertEquals(mapOf("baz" to mapOf("z" to null)), result.toSpecification()["data"]) - assertEquals(listOf(listOf("baz", "z")), result.errors.map { it.path }) - } - - @Test - fun `list of nodes`() { - val result = builder { ctx -> - val internalId = ctx.id.internalID.toInt() - if (internalId % 2 == 0) { - throw RuntimeException("msg") - } else { - Baz.Builder(ctx).x(internalId).build() - } - } - .build() - .execute("{ bazList { x } }") - - val expectedResultData = mapOf( - "bazList" to listOf( - mapOf("x" to 1), - null, - mapOf("x" to 3), - null, - mapOf("x" to 5), - ), - ) - - assertEquals(expectedResultData, result.toSpecification()["data"]) - assertEquals(listOf(listOf("bazList", 1), listOf("bazList", 3)), result.errors.map { it.path }) - } - - @Test - fun `node resolver does not batch`() { - val execCounts = ConcurrentHashMap() - val result = builder { ctx -> - val internalId = ctx.id.internalID - execCounts.computeIfAbsent(internalId) { AtomicInteger(0) }.incrementAndGet() - Baz.Builder(ctx).x(internalId.toInt()).build() - } - .build() - .execute("{bazList { x }}") - - // Verify each node was resolved individually (not batched) - assertEquals(mapOf("1" to 1, "2" to 1, "3" to 1, "4" to 1, "5" to 1), execCounts.mapValues { it.value.get() }) - - // Verify the results are correct - val expectedData = mapOf( - "bazList" to listOf( - mapOf("x" to 1), - mapOf("x" to 2), - mapOf("x" to 3), - mapOf("x" to 4), - mapOf("x" to 5) - ) - ) - assertEquals(expectedData, result.toSpecification()["data"]) - } - - @Test - @Disabled("flaky") - fun `node resolver reads from dataloader cache`() { - val execCounts = ConcurrentHashMap() - builder { ctx -> - val internalId = ctx.id.internalID - execCounts.computeIfAbsent(internalId) { AtomicInteger(0) }.incrementAndGet() - Baz.Builder(ctx).x(2).build() - } - .resolver("Query" to "baz") { ctx -> - ctx.nodeFor(ctx.globalIDFor(Baz.Reflection, "1")) - } - .resolver("Baz" to "anotherBaz") { ctx -> - ctx.nodeFor(ctx.globalIDFor(Baz.Reflection, "1")) - } - .build() - .execute("{ baz { x anotherBaz { id x }}}") - .assertJson("""{"data": {"baz": {"x":2, "anotherBaz":{"id":"QmF6OjE=", "x":2}}}}""") - - assertEquals(mapOf("1" to 1), execCounts.mapValues { it.value.get() }) - } - - @Test - fun `node resolver does not read from dataloader cache if selection set does not cover`() { - val execCounts = ConcurrentHashMap() - builder { ctx -> - val internalId = ctx.id.internalID - execCounts.computeIfAbsent(internalId) { AtomicInteger(0) }.incrementAndGet() - Baz.Builder(ctx).x(2).x2("foo").build() - } - .resolver("Query" to "baz") { ctx -> - ctx.nodeFor(ctx.globalIDFor(Baz.Reflection, "1")) - } - .resolver("Baz" to "anotherBaz") { ctx -> - ctx.nodeFor(ctx.globalIDFor(Baz.Reflection, "1")) - } - .build() - .execute("{ baz { x anotherBaz { x x2 }}}") - .assertJson("""{"data": {"baz": {"x":2, "anotherBaz":{"x":2, "x2":"foo"}}}}""") - - assertEquals(mapOf("1" to 2), execCounts.mapValues { it.value.get() }) - } -}