diff --git a/.run/Update Test Snapshots.run.xml b/.run/Update Test Snapshots.run.xml new file mode 100644 index 000000000..dbff67a7e --- /dev/null +++ b/.run/Update Test Snapshots.run.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index fbe07a171..0cca3b393 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -29,7 +29,8 @@ dependencies { testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.1") testImplementation("org.slf4j:slf4j-simple:$slf4jVersion") - testImplementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") + testImplementation("com.fasterxml.jackson.core:jackson-databind:2.17.0") + testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0") testImplementation("org.openjdk.jmh:jmh-core:1.37") testImplementation("org.openjdk.jmh:jmh-generator-annprocess:1.37") diff --git a/lib/src/main/java/graphql/nadel/NextgenEngine.kt b/lib/src/main/java/graphql/nadel/NextgenEngine.kt index 1c2914e68..ae4fcf085 100644 --- a/lib/src/main/java/graphql/nadel/NextgenEngine.kt +++ b/lib/src/main/java/graphql/nadel/NextgenEngine.kt @@ -44,6 +44,8 @@ import graphql.nadel.instrumentation.parameters.NadelInstrumentationTimingParame import graphql.nadel.instrumentation.parameters.NadelInstrumentationTimingParameters.RootStep import graphql.nadel.instrumentation.parameters.child import graphql.nadel.schema.NadelDirectives.namespacedDirectiveDefinition +import graphql.nadel.result.NadelResultMerger +import graphql.nadel.result.NadelResultTracker import graphql.nadel.util.OperationNameUtil import graphql.normalized.ExecutableNormalizedField import graphql.normalized.ExecutableNormalizedOperationFactory.createExecutableNormalizedOperationWithRawVariables @@ -160,6 +162,7 @@ internal class NextgenEngine( } val incrementalResultSupport = NadelIncrementalResultSupport() + val resultTracker = NadelResultTracker() val executionContext = NadelExecutionContext( executionInput, query, @@ -168,6 +171,7 @@ internal class NextgenEngine( instrumentationState, timer, incrementalResultSupport, + resultTracker, ) val beginExecuteContext = instrumentation.beginExecute( @@ -214,6 +218,9 @@ internal class NextgenEngine( beginExecuteContext?.onCompleted(result, null) incrementalResultSupport.onInitialResultComplete() + // todo: maybe pass in the incremental version that's built below into here + resultTracker.complete(result) + return if (incrementalResultSupport.hasDeferredResults()) { IncrementalExecutionResultImpl.Builder() .from(result) diff --git a/lib/src/main/java/graphql/nadel/engine/NadelExecutionContext.kt b/lib/src/main/java/graphql/nadel/engine/NadelExecutionContext.kt index 636170727..17d5c44cd 100644 --- a/lib/src/main/java/graphql/nadel/engine/NadelExecutionContext.kt +++ b/lib/src/main/java/graphql/nadel/engine/NadelExecutionContext.kt @@ -9,6 +9,7 @@ import graphql.nadel.ServiceExecutionHydrationDetails import graphql.nadel.engine.instrumentation.NadelInstrumentationTimer import graphql.nadel.hooks.CreateServiceContextParams import graphql.nadel.hooks.NadelExecutionHooks +import graphql.nadel.result.NadelResultTracker import graphql.normalized.ExecutableNormalizedOperation import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap @@ -21,6 +22,7 @@ data class NadelExecutionContext internal constructor( val instrumentationState: InstrumentationState?, internal val timer: NadelInstrumentationTimer, internal val incrementalResultSupport: NadelIncrementalResultSupport, + internal val resultTracker: NadelResultTracker, internal val hydrationDetails: ServiceExecutionHydrationDetails? = null, ) { private val serviceContexts = ConcurrentHashMap>() diff --git a/lib/src/main/java/graphql/nadel/engine/transform/hydration/NadelHydrationTransform.kt b/lib/src/main/java/graphql/nadel/engine/transform/hydration/NadelHydrationTransform.kt index 6a51ba594..c2bf39a29 100644 --- a/lib/src/main/java/graphql/nadel/engine/transform/hydration/NadelHydrationTransform.kt +++ b/lib/src/main/java/graphql/nadel/engine/transform/hydration/NadelHydrationTransform.kt @@ -19,21 +19,23 @@ import graphql.nadel.engine.transform.NadelTransformUtil.makeTypeNameField import graphql.nadel.engine.transform.artificial.NadelAliasHelper import graphql.nadel.engine.transform.getInstructionsForNode import graphql.nadel.engine.transform.hydration.NadelHydrationTransform.State -import graphql.nadel.engine.transform.hydration.NadelHydrationUtil.getInstructionsToAddErrors import graphql.nadel.engine.transform.query.NadelQueryPath import graphql.nadel.engine.transform.query.NadelQueryTransformer import graphql.nadel.engine.transform.result.NadelResultInstruction -import graphql.nadel.engine.transform.result.NadelResultKey import graphql.nadel.engine.transform.result.json.JsonNode import graphql.nadel.engine.transform.result.json.JsonNodeExtractor import graphql.nadel.engine.transform.result.json.JsonNodes +import graphql.nadel.engine.util.JsonMap import graphql.nadel.engine.util.emptyOrSingle import graphql.nadel.engine.util.getFieldDefinitionSequence import graphql.nadel.engine.util.isList import graphql.nadel.engine.util.queryPath import graphql.nadel.engine.util.toBuilder +import graphql.nadel.engine.util.toGraphQLError import graphql.nadel.engine.util.unwrapNonNull import graphql.nadel.hooks.NadelExecutionHooks +import graphql.nadel.result.NadelResultPath +import graphql.nadel.result.NadelResultPathSegment import graphql.normalized.ExecutableNormalizedField import graphql.schema.FieldCoordinates import kotlinx.coroutines.async @@ -175,7 +177,7 @@ internal class NadelHydrationTransform( ): List { return coroutineScope { parentNodes - .map { + .mapNotNull { prepareHydration( parentNode = it, state = state, @@ -190,7 +192,25 @@ internal class NadelHydrationTransform( } } .awaitAll() - .flatten() + .flatMap { hydration -> + val setData = sequenceOf( + NadelResultInstruction.Set( + subject = hydration.parentNode, + newValue = hydration.newValue, + field = overallField, + ), + ) + val addErrors = hydration.errors + .asSequence() + .map { error -> + toGraphQLError(error) + } + .map { + NadelResultInstruction.AddError(it) + } + + setData + addErrors + } } } @@ -203,60 +223,60 @@ internal class NadelHydrationTransform( ) { // Prepare the hydrations before we go async // We need to do this because if we run it async below, we cannot guarantee that our artificial fields have not yet been removed - val hydrations = parentNodes.map { - prepareHydration( - parentNode = it, - state = state, - executionBlueprint = executionBlueprint, - fieldToHydrate = overallField, - executionContext = executionContext, - ) - } + val preparedHydrations = parentNodes + .mapNotNull { + prepareHydration( + parentNode = it, + state = state, + executionBlueprint = executionBlueprint, + fieldToHydrate = overallField, + executionContext = executionContext, + ) + } // This isn't really right… but we start with this val label = overallField.deferredExecutions.firstNotNullOfOrNull { it.label } executionContext.incrementalResultSupport.defer { - val instructionSequence = hydrations + val hydrations = preparedHydrations .map { async { it.hydrate() } } .awaitAll() - .asSequence() - .flatten() - - val results = instructionSequence - .filterIsInstance() - .emptyOrSingle() DelayedIncrementalPartialResultImpl.Builder() .incrementalItems( - listOf( - DeferPayload.Builder() - .label(label) - .data( - mapOf( - overallField.resultKey to results?.newValue?.value, - ), - ) - .path( - overallField.parent?.listOfResultKeys?.let { - @Suppress("USELESS_CAST") // It's not useless because Java (yay) - it as List - } ?: emptyList() - ) - .errors( - instructionSequence - .filterIsInstance() - .map { - it.error - } - .toList(), - ) - .build(), - ), + hydrations + .map { hydration -> // Hydration of one parent node + val data = hydration.newValue + + val parentPath = executionContext.resultTracker.getResultPath( + overallField.queryPath.dropLast(1), + hydration.parentNode, + )!! + val path = parentPath + overallField.resultKey + + DeferPayload.newDeferredItem() + .label(label) + .data( + mapOf( + overallField.resultKey to data?.value, + ), + ) + .path(parentPath.toRawPath()) + .errors( + hydration.errors + .map { + toGraphQLError( + raw = it, + path = path.toRawPath(), + ) + }, + ) + .build() + } ) .build() } @@ -268,7 +288,7 @@ internal class NadelHydrationTransform( executionBlueprint: NadelOverallExecutionBlueprint, fieldToHydrate: ExecutableNormalizedField, // Field asking for hydration from the overall query executionContext: NadelExecutionContext, - ): NadelPreparedHydration { + ): NadelPreparedHydration? { val instructions = state.instructionsByObjectTypeNames.getInstructionsForNode( executionBlueprint = executionBlueprint, service = state.hydratedFieldService, @@ -278,19 +298,15 @@ internal class NadelHydrationTransform( // Do nothing if there is no hydration instruction associated with this result if (instructions.isEmpty()) { - return NadelPreparedHydration { - emptyList() - } + return null } val instruction = getHydrationFieldInstruction(state, instructions, executionContext.hooks, parentNode) ?: return NadelPreparedHydration { - listOf( - NadelResultInstruction.Set( - subject = parentNode, - key = NadelResultKey(state.hydratedField.resultKey), - newValue = null, - ), + NadelHydrationResult( + parentNode = parentNode, + newValue = null, + errors = emptyList(), ) } @@ -343,33 +359,26 @@ internal class NadelHydrationTransform( ).emptyOrSingle() } - val errors = result?.let(::getInstructionsToAddErrors) ?: emptyList() - - listOf( - NadelResultInstruction.Set( - subject = parentNode, - key = NadelResultKey(fieldToHydrate.resultKey), - newValue = JsonNode(data?.value), - ), - ) + errors + NadelHydrationResult( + parentNode = parentNode, + newValue = JsonNode(data?.value), + errors = result?.errors ?: emptyList(), + ) } is NadelHydrationStrategy.ManyToOne -> { - val data = actorQueryResults.map { result -> - JsonNodeExtractor.getNodesAt( - data = result.data, - queryPath = instruction.queryPathToActorField, - ).emptyOrSingle()?.value - } - - val addErrors = getInstructionsToAddErrors(actorQueryResults) + val data = actorQueryResults + .map { result -> + JsonNodeExtractor.getNodesAt( + data = result.data, + queryPath = instruction.queryPathToActorField, + ).emptyOrSingle()?.value + } - listOf( - NadelResultInstruction.Set( - subject = parentNode, - key = NadelResultKey(fieldToHydrate.resultKey), - newValue = JsonNode(data), - ), - ) + addErrors + NadelHydrationResult( + parentNode = parentNode, + newValue = JsonNode(data), + errors = actorQueryResults.flatMap { it.errors }, + ) } } } @@ -409,12 +418,7 @@ internal class NadelHydrationTransform( return false } - return if (executionContext.hints.deferSupport() && overallField.deferredExecutions.isNotEmpty()) { - // We currently don't support defer if the hydration is inside a List - return !areAnyParentFieldsOutputtingLists(overallField, executionBlueprint) - } else { - false - } + return executionContext.hints.deferSupport() && overallField.deferredExecutions.isNotEmpty() } private fun areAnyParentFieldsOutputtingLists( @@ -466,5 +470,11 @@ internal class NadelHydrationTransform( * So we "prepare" a hydration to ensure we have the value of the artificial field before it gets removed. */ private fun interface NadelPreparedHydration { - suspend fun hydrate(): List + suspend fun hydrate(): NadelHydrationResult } + +private data class NadelHydrationResult( + val parentNode: JsonNode, + val newValue: JsonNode?, + val errors: List, +) diff --git a/lib/src/main/java/graphql/nadel/engine/transform/hydration/NadelHydrationUtil.kt b/lib/src/main/java/graphql/nadel/engine/transform/hydration/NadelHydrationUtil.kt index eb7cdedbe..02c5172c8 100644 --- a/lib/src/main/java/graphql/nadel/engine/transform/hydration/NadelHydrationUtil.kt +++ b/lib/src/main/java/graphql/nadel/engine/transform/hydration/NadelHydrationUtil.kt @@ -13,35 +13,35 @@ internal object NadelHydrationUtil { @JvmName("getInstructionsToAddErrors_2") fun getInstructionsToAddErrors( results: List, - ): List { + ): List { return results .asSequence() .map(NadelResolvedObjectBatch::result) - .flatMap(::sequenceOfInstructionsToAddErrors) + .flatMap(::getInstructionsToAddErrorsSequence) .toList() } fun getInstructionsToAddErrors( results: List, - ): List { + ): List { return results .asSequence() - .flatMap(::sequenceOfInstructionsToAddErrors) + .flatMap(::getInstructionsToAddErrorsSequence) .toList() } fun getInstructionsToAddErrors( result: ServiceExecutionResult, - ): List { - return sequenceOfInstructionsToAddErrors(result).toList() + ): List { + return getInstructionsToAddErrorsSequence(result).toList() } /** * Do not expose sequences as those */ - private fun sequenceOfInstructionsToAddErrors( + private fun getInstructionsToAddErrorsSequence( result: ServiceExecutionResult, - ): Sequence { + ): Sequence { return result.errors .asSequence() .map(::toGraphQLError) diff --git a/lib/src/main/java/graphql/nadel/engine/transform/result/NadelResultTransformer.kt b/lib/src/main/java/graphql/nadel/engine/transform/result/NadelResultTransformer.kt index e13f25e91..9a94c6de1 100644 --- a/lib/src/main/java/graphql/nadel/engine/transform/result/NadelResultTransformer.kt +++ b/lib/src/main/java/graphql/nadel/engine/transform/result/NadelResultTransformer.kt @@ -114,15 +114,17 @@ internal class NadelResultTransformer(private val executionBlueprint: NadelOvera return artificialFields .asSequence() .flatMap { field -> - nodes.getNodesAt( - queryPath = field.queryPath.dropLast(1), - flatten = true, - ).map { parentNode -> - NadelResultInstruction.Remove( - subject = parentNode, - key = NadelResultKey(field.resultKey), + nodes + .getNodesAt( + queryPath = field.queryPath.dropLast(1), + flatten = true, ) - } + .map { parentNode -> + NadelResultInstruction.Remove( + subject = parentNode, + key = NadelResultKey(field.resultKey), + ) + } } .toList() } diff --git a/lib/src/main/java/graphql/nadel/engine/transform/result/json/JsonNodes.kt b/lib/src/main/java/graphql/nadel/engine/transform/result/json/JsonNodes.kt index fd7d85c14..b801c2461 100644 --- a/lib/src/main/java/graphql/nadel/engine/transform/result/json/JsonNodes.kt +++ b/lib/src/main/java/graphql/nadel/engine/transform/result/json/JsonNodes.kt @@ -6,18 +6,38 @@ import graphql.nadel.engine.util.AnyMap import graphql.nadel.engine.util.JsonMap import java.util.concurrent.ConcurrentHashMap +/** + * Generic interface to extract a [JsonNode] from the result for a given [NadelQueryPath]. + * + * Use [NadelCachingJsonNodes] for the most part because that is faster. + * It is the default implementation. + */ +interface JsonNodes { + /** + * Extracts the nodes at the given query selection path. + */ + fun getNodesAt(queryPath: NadelQueryPath, flatten: Boolean = false): List + + companion object { + internal var nodesFactory: (JsonMap) -> JsonNodes = { + NadelCachingJsonNodes(it) + } + + operator fun invoke(data: JsonMap): JsonNodes { + return nodesFactory(data) + } + } +} + /** * Utility class to extract data out of the given [data]. */ -class JsonNodes( +class NadelCachingJsonNodes( private val data: JsonMap, -) { +) : JsonNodes { private val nodes = ConcurrentHashMap>() - /** - * Extracts the nodes at the given query selection path. - */ - fun getNodesAt(queryPath: NadelQueryPath, flatten: Boolean = false): List { + override fun getNodesAt(queryPath: NadelQueryPath, flatten: Boolean): List { val rootNode = JsonNode(data) return getNodesAt(rootNode, queryPath, flatten) } @@ -73,7 +93,7 @@ class JsonNodes( } return sequenceOf( - JsonNode(value), + JsonNode(value = value), ) } @@ -89,15 +109,15 @@ class JsonNodes( * * etc. */ - private fun getFlatNodes(values: AnyList): Sequence { + private fun getFlatNodes( + values: AnyList, + ): Sequence { return values .asSequence() .flatMap { value -> when (value) { is AnyList -> getFlatNodes(value) - else -> sequenceOf( - JsonNode(value), - ) + else -> sequenceOf(JsonNode(value = value)) } } } diff --git a/lib/src/main/java/graphql/nadel/engine/transform/result/json/NadelJsonNodeIterator.kt b/lib/src/main/java/graphql/nadel/engine/transform/result/json/NadelJsonNodeIterator.kt new file mode 100644 index 000000000..d9984c4c8 --- /dev/null +++ b/lib/src/main/java/graphql/nadel/engine/transform/result/json/NadelJsonNodeIterator.kt @@ -0,0 +1,204 @@ +package graphql.nadel.engine.transform.result.json + +import graphql.nadel.engine.transform.query.NadelQueryPath +import graphql.nadel.engine.transform.result.json.NadelEphemeralJsonNode.Companion.component1 +import graphql.nadel.engine.transform.result.json.NadelEphemeralJsonNode.Companion.component2 +import graphql.nadel.engine.transform.result.json.NadelEphemeralJsonNode.Companion.component3 +import graphql.nadel.engine.util.AnyList +import graphql.nadel.engine.util.AnyMap +import graphql.nadel.result.NadelResultPath +import graphql.nadel.result.NadelResultPathSegment + +/** + * In theory what should be the [JsonNodeExtractor] replacement. + * + * Though, replacement is a tall order they have different iteration patterns. + * + * Because of that, things like GraphQL queries to underlying services are different + * and the tests need to be updated. + * + * So for now, I'll leave this in here in case a new feature uses it in the future. + */ +internal class NadelIteratingJsonNodes( + private val data: Any?, +) : JsonNodes { + override fun getNodesAt(queryPath: NadelQueryPath, flatten: Boolean): List { + val iterator = NadelJsonNodeIterator( + root = data, + queryPath = queryPath, + flatten = flatten, + ) + + // So, I actually tested and using a Sequence here is somehow significantly slower + // So let's stick with the good ol for loop + val results = mutableListOf() + iterator.forEach { (elementQueryPath, elementResultPath, element) -> + if (elementQueryPath.size == queryPath.segments.size) { + results.add(JsonNode(element)) + } + } + + return results + } +} + +/** + * A JSON node [value] with [queryPath] and [resultPath] values. + * + * You should not store the [NadelEphemeralJsonNode] as there is only one instance. + * Its values will change, but the enclosing [NadelEphemeralJsonNode] instance iis the same. + * + * The values should not be stored either. + * + * This exists because of performance reasons, we avoid object allocations and reuse + * the same [List] values to avoid creating multiple arrays. + * + * The majority of the time the [resultPath] values are discarded anyway. + */ +internal abstract class NadelEphemeralJsonNode { + abstract val queryPath: List + abstract val resultPath: NadelResultPath + abstract val value: Any? + + companion object { + operator fun NadelEphemeralJsonNode.component1(): List = queryPath + operator fun NadelEphemeralJsonNode.component2(): NadelResultPath = resultPath + operator fun NadelEphemeralJsonNode.component3(): Any? = value + } +} + +/** + * Does a DFS search through the response to the given `queryPath`. + */ +internal class NadelJsonNodeIterator( + root: Any?, + queryPath: NadelQueryPath, + private val flatten: Boolean, +) : Iterator { + private var hasNext = true + + override fun hasNext(): Boolean { + return hasNext || calculateNext() + } + + override fun next(): NadelEphemeralJsonNode { + if (!hasNext && !calculateNext()) { + throw NoSuchElementException() + } + + hasNext = false + ephemeralJsonNode.value = parents.last() + + return ephemeralJsonNode + } + + private val queryPathSegments = queryPath.segments + private val currentQueryPathSegments = ArrayList(queryPathSegments.size) + private val currentResultPathSegments = ArrayList(queryPathSegments.size + resultBuffer) + + companion object { + /** + * A random guess at a ceiling of how many indices a result path should have over the query path. + * + * e.g. query path could be [issues, users, next, friends, enemies] and a result path + * could be [issues, 0, users, 10, next, friends, 2, enemies, 5] + * + * So in this case our result path has 4 more elements than the query path. + * + * We use this buffer value to create a "right sized" [List] for storing the result path etc. + */ + private const val resultBuffer = 6 + + private val NONE = Any() + } + + private val ephemeralJsonNode = object : NadelEphemeralJsonNode() { + override val queryPath get() = currentQueryPathSegments + override val resultPath get() = NadelResultPath(currentResultPathSegments) + override var value: Any? = NONE + } + + /** + * These are the parents of the current element, and includes the current element + * at the end of a traversal iteration. + */ + private val parents: MutableList = ArrayList(queryPathSegments.size + resultBuffer).also { + it.add(root) + } + + private val objectPathSegmentCache = queryPathSegments.associateWith(NadelResultPathSegment::Object) + + private fun calculateNext(): Boolean { + val advanced: Boolean = when (val current = parents.last()) { + is AnyList -> { + if (currentQueryPathSegments.size == queryPathSegments.size && !flatten) { + // Shortcut to avoid traversing children at all if not asked for + false + } else { + if (current.isEmpty()) { + false + } else { + if (currentResultPathSegments.lastIndex < parents.lastIndex) { + // Traverse children + currentResultPathSegments.add(NadelResultPathSegment.Array(current.lastIndex)) + } + + val arraySegment = currentResultPathSegments[parents.lastIndex] as NadelResultPathSegment.Array + parents.add(current[arraySegment.index]) + true + } + } + } + is AnyMap -> { + if (currentQueryPathSegments.size < queryPathSegments.size) { + val nextQueryPathSegment = queryPathSegments[currentQueryPathSegments.lastIndex + 1] + val nextElement = current.getOrDefault(nextQueryPathSegment, NONE) + if (nextElement === NONE) { + false + } else { + currentQueryPathSegments.add(nextQueryPathSegment) + currentResultPathSegments.add(objectPathSegmentCache[nextQueryPathSegment]!!) + parents.add(nextElement) + true + } + } else { + false + } + } + else -> { + false + } + } + + if (!advanced) { + while (currentResultPathSegments.isNotEmpty()) { + val last = currentResultPathSegments.lastOrNull() ?: break + + when (last) { + is NadelResultPathSegment.Array -> { + if (last.index == 0) { + // Nothing more to visit in the array, remember that we traverse end -> front + currentResultPathSegments.removeLast() + parents.removeLast() + } else { + // We're moving to the next element + currentResultPathSegments[currentResultPathSegments.lastIndex] = + NadelResultPathSegment.Array(last.index - 1) + parents.removeLast() + // Iterate to next element + return calculateNext() + } + } + is NadelResultPathSegment.Object -> { + currentResultPathSegments.removeLast() + currentQueryPathSegments.removeLast() + parents.removeLast() + } + } + } + } + + hasNext = currentResultPathSegments.isNotEmpty() + return hasNext + } +} diff --git a/lib/src/main/java/graphql/nadel/engine/util/GraphQLUtil.kt b/lib/src/main/java/graphql/nadel/engine/util/GraphQLUtil.kt index 341436cb5..5fa6f5742 100644 --- a/lib/src/main/java/graphql/nadel/engine/util/GraphQLUtil.kt +++ b/lib/src/main/java/graphql/nadel/engine/util/GraphQLUtil.kt @@ -89,16 +89,22 @@ fun newGraphQLError( .build() } +/** + * Maps a [raw] [JsonMap] to a [GraphQLError] with optional overrides in the parameters. + */ fun toGraphQLError( raw: JsonMap, + message: String? = raw["message"] as String?, + extensions: JsonMap? = raw["extensions"] as JsonMap?, + path: AnyList? = raw["path"] as AnyList?, ): GraphQLError { val errorBuilder = newError() - .message((raw["message"] as String?) ?: "An error has occurred") - raw["extensions"]?.let { extensions -> - errorBuilder.extensions(extensions as JsonMap) + .message(message ?: "An error has occurred") + if (extensions != null) { + errorBuilder.extensions(extensions) } - raw["path"]?.let { path -> - errorBuilder.path(path as AnyList) + if (path != null) { + errorBuilder.path(path) } return errorBuilder.build() } diff --git a/lib/src/main/java/graphql/nadel/instrumentation/NadelInstrumentation.kt b/lib/src/main/java/graphql/nadel/instrumentation/NadelInstrumentation.kt index be001ac8c..5aa058ef5 100644 --- a/lib/src/main/java/graphql/nadel/instrumentation/NadelInstrumentation.kt +++ b/lib/src/main/java/graphql/nadel/instrumentation/NadelInstrumentation.kt @@ -5,6 +5,7 @@ import graphql.execution.instrumentation.InstrumentationContext import graphql.execution.instrumentation.InstrumentationState import graphql.execution.instrumentation.SimpleInstrumentationContext.noOp import graphql.language.Document +import graphql.nadel.engine.NadelExecutionContext import graphql.nadel.instrumentation.parameters.NadelInstrumentationCreateStateParameters import graphql.nadel.instrumentation.parameters.NadelInstrumentationExecuteOperationParameters import graphql.nadel.instrumentation.parameters.NadelInstrumentationOnErrorParameters @@ -111,4 +112,10 @@ interface NadelInstrumentation { */ fun onError(parameters: NadelInstrumentationOnErrorParameters) { } + + /** + * todo: create a root "internal" implementation so we can effectively have internal interface methods + */ + fun onExecutionContext(context: NadelExecutionContext) { + } } diff --git a/lib/src/main/java/graphql/nadel/NadelResultMerger.kt b/lib/src/main/java/graphql/nadel/result/NadelResultMerger.kt similarity index 98% rename from lib/src/main/java/graphql/nadel/NadelResultMerger.kt rename to lib/src/main/java/graphql/nadel/result/NadelResultMerger.kt index 4567f541a..1fd44cd22 100644 --- a/lib/src/main/java/graphql/nadel/NadelResultMerger.kt +++ b/lib/src/main/java/graphql/nadel/result/NadelResultMerger.kt @@ -1,9 +1,10 @@ -package graphql.nadel +package graphql.nadel.result import graphql.ExecutionResult import graphql.ExecutionResultImpl import graphql.GraphQLError import graphql.introspection.Introspection +import graphql.nadel.ServiceExecutionResult import graphql.nadel.engine.transform.query.NadelFieldAndService import graphql.nadel.engine.transform.result.NadelResultKey import graphql.nadel.engine.util.AnyMap diff --git a/lib/src/main/java/graphql/nadel/result/NadelResultPath.kt b/lib/src/main/java/graphql/nadel/result/NadelResultPath.kt new file mode 100644 index 000000000..f9115bcc8 --- /dev/null +++ b/lib/src/main/java/graphql/nadel/result/NadelResultPath.kt @@ -0,0 +1,31 @@ +package graphql.nadel.result + +@JvmInline +internal value class NadelResultPath( + val value: List, +) { + operator fun plus(key: String): NadelResultPath { + return NadelResultPath(value + NadelResultPathSegment.Object(key)) + } + + operator fun plus(key: Int): NadelResultPath { + return NadelResultPath(value + NadelResultPathSegment.Array(key)) + } + + fun clone(): NadelResultPath { + return NadelResultPath(value = value.toList()) + } + + fun toRawPath(): List { + return value.map { + when (it) { + is NadelResultPathSegment.Array -> it.index + is NadelResultPathSegment.Object -> it.key + } + } + } + + companion object { + val empty = NadelResultPath(value = emptyList()) + } +} diff --git a/lib/src/main/java/graphql/nadel/result/NadelResultPathBuilder.kt b/lib/src/main/java/graphql/nadel/result/NadelResultPathBuilder.kt new file mode 100644 index 000000000..b05db228e --- /dev/null +++ b/lib/src/main/java/graphql/nadel/result/NadelResultPathBuilder.kt @@ -0,0 +1,39 @@ +package graphql.nadel.result + +internal interface NadelResultPathBuilder { + fun add(key: String): NadelResultPathBuilder + fun add(index: Int): NadelResultPathBuilder + fun build(): NadelResultPath + + companion object { + operator fun invoke( + path: List = emptyList(), + ): NadelResultPathBuilder { + return object : NadelResultPathBuilder { + private var segments = path.toMutableList() + + override fun add(key: String): NadelResultPathBuilder { + segments.add(NadelResultPathSegment.Object(key)) + return this + } + + override fun add(index: Int): NadelResultPathBuilder { + segments.add(NadelResultPathSegment.Array(index)) + return this + } + + override fun build(): NadelResultPath { + return NadelResultPath(segments) + } + } + } + } +} + +internal fun NadelResultPath.toBuilder(): NadelResultPathBuilder { + return NadelResultPathBuilder(value) +} + +internal fun List.toBuilder(): NadelResultPathBuilder { + return NadelResultPathBuilder(this) +} diff --git a/lib/src/main/java/graphql/nadel/result/NadelResultPathSegment.kt b/lib/src/main/java/graphql/nadel/result/NadelResultPathSegment.kt new file mode 100644 index 000000000..aca970371 --- /dev/null +++ b/lib/src/main/java/graphql/nadel/result/NadelResultPathSegment.kt @@ -0,0 +1,17 @@ +package graphql.nadel.result + +internal sealed interface NadelResultPathSegment { + @JvmInline + value class Object(val key: String) : NadelResultPathSegment + + @JvmInline + value class Array private constructor(val index: Int) : NadelResultPathSegment { + companion object { + private val cache = List(2000, ::Array) + + operator fun invoke(index: Int): Array { + return cache.getOrNull(index) ?: Array(index) + } + } + } +} diff --git a/lib/src/main/java/graphql/nadel/result/NadelResultTracker.kt b/lib/src/main/java/graphql/nadel/result/NadelResultTracker.kt new file mode 100644 index 000000000..3026387bb --- /dev/null +++ b/lib/src/main/java/graphql/nadel/result/NadelResultTracker.kt @@ -0,0 +1,51 @@ +package graphql.nadel.result + +import graphql.ExecutionResult +import graphql.nadel.engine.transform.query.NadelQueryPath +import graphql.nadel.engine.transform.result.json.JsonNode +import graphql.nadel.engine.transform.result.json.NadelJsonNodeIterator +import kotlinx.coroutines.CompletableDeferred + +/** + * todo: this needs to track multiple responses + */ +internal class NadelResultTracker { + private val result = CompletableDeferred() + + /** + * So… in Nadel the result can change a lot. + * + * This function lets you track where a result node went to in the overall response sent to the user. + * + * I haven't benchmarked this yet, but in theory it should be more performant than say using + * [graphql.nadel.engine.transform.result.json.JsonNodes]. + * + * In the past we used to track the [NadelResultPathSegment]s for each [JsonNode] but that was horrible + * performance wise because we created one List for each result node + * i.e. as the result grew, both in depth and result node count, you'd allocate tons of (big) lists. + * + * This implementation keeps track of the _current_ [NadelResultPathSegment]s and returns that if it + * finds the [node] in question. + */ + suspend fun getResultPath( + queryPath: NadelQueryPath, + node: JsonNode, + ): NadelResultPath? { + val result = result.await() + val data = result.toSpecification()["data"] + + val jsonNodeIterator = NadelJsonNodeIterator(root = data, queryPath = queryPath, flatten = true) + for (ephemeralNode in jsonNodeIterator) { + if (ephemeralNode.queryPath.size == queryPath.segments.size && ephemeralNode.value === node.value) { + // Clone because underlying values are ephemeral too + return ephemeralNode.resultPath.clone() + } + } + + return null + } + + fun complete(value: ExecutionResult) { + result.complete(value) + } +} diff --git a/lib/src/test/kotlin/graphql/nadel/Jackson.kt b/lib/src/test/kotlin/graphql/nadel/Jackson.kt new file mode 100644 index 000000000..3789dcc16 --- /dev/null +++ b/lib/src/test/kotlin/graphql/nadel/Jackson.kt @@ -0,0 +1,5 @@ +package graphql.nadel + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +val jsonObjectMapper = jacksonObjectMapper() diff --git a/lib/src/test/kotlin/graphql/nadel/engine/transform/result/json/NadelJsonNodeIteratorTest.kt b/lib/src/test/kotlin/graphql/nadel/engine/transform/result/json/NadelJsonNodeIteratorTest.kt new file mode 100644 index 000000000..f80de6610 --- /dev/null +++ b/lib/src/test/kotlin/graphql/nadel/engine/transform/result/json/NadelJsonNodeIteratorTest.kt @@ -0,0 +1,600 @@ +package graphql.nadel.engine.transform.result.json + +import com.fasterxml.jackson.module.kotlin.readValue +import graphql.nadel.engine.transform.query.NadelQueryPath +import graphql.nadel.engine.util.JsonMap +import graphql.nadel.jsonObjectMapper +import graphql.nadel.result.NadelResultPath +import graphql.nadel.result.NadelResultPathBuilder +import org.junit.jupiter.api.Test +import kotlin.test.assertTrue + +class NadelJsonNodeIteratorTest { + private data class TraversedJsonNode( + override val queryPath: List, + override val resultPath: NadelResultPath, + override val value: Any?, + ) : NadelEphemeralJsonNode() { + constructor(other: NadelEphemeralJsonNode) : this( + queryPath = other.queryPath.toList(), + resultPath = other.resultPath.clone(), + value = other.value, + ) + } + + @Test + fun traverseObjects() { + val root = jsonObjectMapper.readValue( + // language=JSON + """ + { + "users": { + "id": "100", + "friend": { + "id": "100", + "phoneNumber": { + "value": "+61" + } + }, + "email": "@.com" + } + } + """.trimIndent(), + ) + + val expectedTraversals = listOf( + TraversedJsonNode( + queryPath = emptyList(), + resultPath = NadelResultPath.empty, + value = root, + ), + TraversedJsonNode( + queryPath = listOf("users"), + resultPath = NadelResultPathBuilder() + .add("users") + .build(), + value = jsonObjectMapper.readValue( + """{"id": "100", "friend": {"id": "100", "phoneNumber": {"value": "+61"}}, "email": "@.com"}""", + ), + ), + TraversedJsonNode( + queryPath = listOf("users", "friend"), + resultPath = NadelResultPathBuilder() + .add("users") + .add("friend") + .build(), + value = jsonObjectMapper.readValue( + """{"id": "100", "phoneNumber": {"value": "+61"}}""", + ), + ), + TraversedJsonNode( + queryPath = listOf("users", "friend", "phoneNumber"), + resultPath = NadelResultPathBuilder() + .add("users") + .add("friend") + .add("phoneNumber") + .build(), + value = jsonObjectMapper.readValue( + """{"value": "+61"}""", + ), + ), + ) + + val iterator = NadelJsonNodeIterator( + root = root, + queryPath = NadelQueryPath(listOf("users", "friend", "phoneNumber")), + flatten = true, + ) + + // When + val traversed = iterator + .asSequence() + .map(::TraversedJsonNode) + .toList() + + // Then + val uniqueQueryPaths = traversed.mapTo(LinkedHashSet()) { it.queryPath } + val uniqueResultPaths = traversed.mapTo(LinkedHashSet()) { it.resultPath } + assertTrue(uniqueResultPaths.size == traversed.size) + assertTrue(uniqueQueryPaths.size == traversed.size) + + assertTrue(traversed.size == expectedTraversals.size) + traversed.zip(expectedTraversals) + .forEach { (actual, expected) -> + assertTrue(actual == expected) + } + } + + @Test + fun traverseNull() { + val root = jsonObjectMapper.readValue( + // language=JSON + """ + { + "users": { + "id": "100", + "friend": null, + "email": "@.com" + } + } + """.trimIndent(), + ) + + val expectedTraversals = listOf( + TraversedJsonNode( + queryPath = emptyList(), + resultPath = NadelResultPath.empty, + value = root, + ), + TraversedJsonNode( + queryPath = listOf("users"), + resultPath = NadelResultPathBuilder() + .add("users") + .build(), + value = jsonObjectMapper.readValue( + """{"id": "100", "friend": null, "email": "@.com"}""", + ), + ), + TraversedJsonNode( + queryPath = listOf("users", "friend"), + resultPath = NadelResultPathBuilder() + .add("users") + .add("friend") + .build(), + value = null, + ), + ) + + val iterator = NadelJsonNodeIterator( + root = root, + queryPath = NadelQueryPath(listOf("users", "friend", "phoneNumber")), + flatten = true, + ) + + // When + val traversed = iterator + .asSequence() + .map(::TraversedJsonNode) + .toList() + + // Then + val uniqueQueryPaths = traversed.mapTo(LinkedHashSet()) { it.queryPath } + val uniqueResultPaths = traversed.mapTo(LinkedHashSet()) { it.resultPath } + assertTrue(uniqueResultPaths.size == traversed.size) + assertTrue(uniqueQueryPaths.size == traversed.size) + + assertTrue(traversed.size == expectedTraversals.size) + traversed.zip(expectedTraversals) + .forEach { (actual, expected) -> + assertTrue(actual == expected) + } + } + + @Test + fun traverseArrays() { + val root = jsonObjectMapper.readValue( + // language=JSON + """ + { + "activities": { + "workedOn": [ + { + "data": { + "id": 1 + } + }, + { + }, + { + "value": { + "friend": null + } + }, + { + "data": { + "friend": { + "id": 10 + } + } + } + ] + } + } + """.trimIndent(), + ) + + val expectedTraversals = listOf( + TraversedJsonNode( + queryPath = emptyList(), + resultPath = NadelResultPathBuilder().build(), + value = root + ), + TraversedJsonNode( + queryPath = listOf("activities"), + resultPath = NadelResultPathBuilder() + .add("activities") + .build(), + value = jsonObjectMapper.readValue( + """{"workedOn": [{"data": {"id": 1}}, {}, {"value": {"friend": null}}, {"data": {"friend": {"id": 10}}}]}""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .build(), + value = jsonObjectMapper.readValue( + """[{"data": {"id": 1}}, {}, {"value": {"friend": null}}, {"data": {"friend": {"id": 10}}}]""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(3) + .build(), + value = jsonObjectMapper.readValue( + """{"data": {"friend": {"id": 10}}}""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn", "data"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(3) + .add("data") + .build(), + value = jsonObjectMapper.readValue( + """{"friend": {"id": 10}}""" + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn", "data", "friend"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(3) + .add("data") + .add("friend") + .build(), + value = jsonObjectMapper.readValue( + """{"id": 10}""" + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(2) + .build(), + value = jsonObjectMapper.readValue( + """{"value": {"friend": null}}""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(1) + .build(), + value = jsonObjectMapper.readValue( + "{}", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(0) + .build(), + value = jsonObjectMapper.readValue( + """{"data": {"id": 1}}""", + ) + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn", "data"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(0) + .add("data") + .build(), + value = jsonObjectMapper.readValue( + """{"id": 1}""", + ), + ), + ) + + val iterator = NadelJsonNodeIterator( + root = root, + queryPath = NadelQueryPath(listOf("activities", "workedOn", "data", "friend")), + flatten = true, + ) + + // When + val traversed = iterator + .asSequence() + .map(::TraversedJsonNode) + .toList() + + // Then + val uniqueResultPaths = traversed.mapTo(LinkedHashSet()) { it.resultPath } + assertTrue(uniqueResultPaths.size == traversed.size) + + assertTrue(traversed.size == expectedTraversals.size) + traversed.zip(expectedTraversals) + .forEach { (actual, expected) -> + assertTrue(actual == expected) + } + } + + @Test + fun traverseNestedArrays() { + val root = jsonObjectMapper.readValue( + // language=JSON + """ + { + "activities": { + "workedOn": [ + [ + { + "data": { + "id": 1 + } + } + ], + [], + [ + { + }, + { + "value": { + "friend": null + } + }, + [ + { + "data": null + } + ] + ], + [ + { + "data": { + "friend": { + "id": 10 + } + } + } + ] + ] + } + } + """.trimIndent(), + ) + + val expectedTraversals = listOf( + TraversedJsonNode( + queryPath = emptyList(), + resultPath = NadelResultPathBuilder().build(), + value = root + ), + TraversedJsonNode( + queryPath = listOf("activities"), + resultPath = NadelResultPathBuilder() + .add("activities") + .build(), + value = jsonObjectMapper.readValue( + """{"workedOn":[[{"data":{"id":1}}],[],[{},{"value":{"friend":null}},[{"data":null}]],[{"data":{"friend":{"id":10}}}]]}""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .build(), + value = jsonObjectMapper.readValue( + """[[{"data":{"id":1}}],[],[{},{"value":{"friend":null}},[{"data":null}]],[{"data":{"friend":{"id":10}}}]]""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(3) + .build(), + value = jsonObjectMapper.readValue( + """[{"data":{"friend":{"id":10}}}]""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(3) + .add(0) + .build(), + value = jsonObjectMapper.readValue( + """{"data":{"friend":{"id":10}}}""" + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn", "data"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(3) + .add(0) + .add("data") + .build(), + value = jsonObjectMapper.readValue( + """{"friend": {"id": 10}}""" + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn", "data", "friend"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(3) + .add(0) + .add("data") + .add("friend") + .build(), + value = jsonObjectMapper.readValue( + """{"id": 10}""" + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(2) + .build(), + value = jsonObjectMapper.readValue( + """[{},{"value":{"friend":null}},[{"data":null}]]""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(2) + .add(2) + .build(), + value = jsonObjectMapper.readValue( + """[{"data":null}]""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(2) + .add(2) + .add(0) + .build(), + value = jsonObjectMapper.readValue( + """{"data":null}""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn", "data"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(2) + .add(2) + .add(0) + .add("data") + .build(), + value = null, + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(2) + .add(1) + .build(), + value = jsonObjectMapper.readValue( + """{"value":{"friend":null}}""" + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(2) + .add(0) + .build(), + value = jsonObjectMapper.readValue( + "{}", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(1) + .build(), + value = jsonObjectMapper.readValue( + "[]", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(0) + .build(), + value = jsonObjectMapper.readValue( + """[{"data":{"id":1}}]""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(0) + .add(0) + .build(), + value = jsonObjectMapper.readValue( + """{"data":{"id":1}}""", + ), + ), + TraversedJsonNode( + queryPath = listOf("activities", "workedOn", "data"), + resultPath = NadelResultPathBuilder() + .add("activities") + .add("workedOn") + .add(0) + .add(0) + .add("data") + .build(), + value = jsonObjectMapper.readValue( + """{"id":1}""", + ), + ), + ) + + val iterator = NadelJsonNodeIterator( + root = root, + queryPath = NadelQueryPath(listOf("activities", "workedOn", "data", "friend")), + flatten = true, + ) + + // When + val traversed = iterator + .asSequence() + .map(::TraversedJsonNode) + .toList() + + // Then + val uniqueResultPaths = traversed.mapTo(LinkedHashSet()) { it.resultPath } + assertTrue(uniqueResultPaths.size == traversed.size) + + assertTrue(traversed.size == expectedTraversals.size) + traversed.zip(expectedTraversals) + .forEach { (actual, expected) -> + assertTrue(actual == expected) + } + } +} diff --git a/lib/src/test/kotlin/graphql/nadel/result/NadelResultTrackerTest.kt b/lib/src/test/kotlin/graphql/nadel/result/NadelResultTrackerTest.kt new file mode 100644 index 000000000..fb4745683 --- /dev/null +++ b/lib/src/test/kotlin/graphql/nadel/result/NadelResultTrackerTest.kt @@ -0,0 +1,566 @@ +package graphql.nadel.result + +import com.fasterxml.jackson.module.kotlin.readValue +import graphql.ExecutionResult +import graphql.nadel.engine.transform.query.NadelQueryPath +import graphql.nadel.engine.transform.result.json.JsonNode +import graphql.nadel.engine.util.AnyList +import graphql.nadel.engine.util.AnyMap +import graphql.nadel.engine.util.JsonMap +import graphql.nadel.engine.util.foldWhileNotNull +import graphql.nadel.engine.util.toGraphQLError +import graphql.nadel.jsonObjectMapper +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertTrue + +class NadelResultTrackerTest { + @Test + fun canSearch2dArrays() = runTest { + // Given + val result = resultOf( + """ + { + "data": [ + [ + {"Hello": null}, + {"World": null} + ], + [ + {"Greetings": null}, + {"Friend": null} + ], + [ + {"Bye": null}, + {"For now": null} + ] + ] + } + """.trimIndent() + ) + val data = result.getData>>() + + val subject = NadelResultTracker() + subject.complete(result) + + // Then + for (i in 0..2) { + for (j in 0..1) { + assertTrue( + subject.getResultPath( + NadelQueryPath.root, + JsonNode(data[i][j]), + ) == NadelResultPathBuilder() + .add(i) + .add(j) + .build(), + ) + } + } + } + + @Test + fun canSearch3dArrays() = runTest { + // Given + val result = resultOf( + """ + { + "data": { + "matrix": [ + [ + [ + {"Hello": null}, + {"World": null} + ], + [ + {"Greetings": null}, + {"Friend": null} + ], + [ + {"Bye": null}, + {"For now": null} + ] + ], + [ + [ + {"Hello": null}, + {"World": null} + ], + [ + {"Greetings": null}, + {"Friend": null} + ], + [ + {"Bye": null}, + {"For now": null} + ] + ], + [ + [ + {"Hello": null}, + {"World": null} + ], + [ + {"Greetings": null}, + {"Friend": null} + ], + [ + {"Bye": null}, + {"For now": null} + ] + ] + ] + } + } + """.trimIndent() + ) + + @Suppress("UNCHECKED_CAST") + val data = result.getData()["matrix"] as List>> + + val subject = NadelResultTracker() + subject.complete(result) + + // Then + for (i in 0..2) { + for (j in 0..2) { + for (k in 0..1) { + assertTrue( + subject.getResultPath( + NadelQueryPath(listOf("matrix")), + JsonNode(data[i][j][k]), + ) == NadelResultPathBuilder() + .add("matrix") + .add(i) + .add(j) + .add(k) + .build(), + ) + } + } + } + } + + /** + * Shouldn't really happen in a real scenario, but we can support it. + * + * i.e. this tests that matrix is not guaranteed to be a 2d or 3d array + */ + @Test + fun canNavigateVariableNesting() = runTest { + // Given + val result = resultOf( + """ + { + "data": { + "matrix": [ + [ + [ + [] + ], + [ + [], + {"World": 10}, + [] + ] + ], + [ + { + "id": 10 + } + ], + { + "name": 10 + } + ] + } + } + """.trimIndent() + ) + + val data = result.getData() + + val subject = NadelResultTracker() + subject.complete(result) + + // Then + JsonNode(data).dfs { path, value -> + if (value is AnyList || value is AnyMap) { + val queryPath = NadelQueryPath( + path + .value + .filterIsInstance() + .map(NadelResultPathSegment.Object::key), + ) + + assertTrue(subject.getResultPath(queryPath, JsonNode(value)) == path) + } + } + } + + /** + * Ensure that when we reach a dead end array, we can keep searching. + */ + @Test + fun searchesEmptyArrays() = runTest { + // Given + val result = resultOf( + """ + { + "data": { + "users": [ + { + "friends": [] + }, + { + "friends": [ + { + "name": "Who was it again?" + } + ] + }, + { + "friends": [] + } + ] + } + } + """.trimIndent() + ) + + val data = result.getData() + + val toFindPath = NadelResultPathBuilder() + .add("users") + .add(1) + .add("friends") + .add(0) + .build() + val toFind = JsonNode(data).getAt(toFindPath)!! + + val subject = NadelResultTracker() + subject.complete(result) + + // Then + val queryPath = NadelQueryPath(listOf("users", "friends")) + + assertTrue(subject.getResultPath(queryPath, JsonNode(toFind)) == toFindPath) + } + + /** + * Ensure that when we reach a dead end array, we can keep searching. + */ + @Test + fun canHandleObjectDeadEnds() = runTest { + // Given + val result = resultOf( + """ + { + "data": { + "users": [ + {}, + { + "friends": [] + }, + { + "friends": [null] + }, + { + "friends": [ + null, + { + "user": { + "name": "Lando Won" + } + }, + {}, + null + ] + }, + { + "friends": [ + null, + {}, + null, + null + ] + }, + { + "friends": null + }, + {} + ] + } + } + """.trimIndent() + ) + + val data = result.getData() + + val toFindPath = NadelResultPathBuilder() + .add("users") + .add(3) + .add("friends") + .add(1) + .add("user") + .build() + val toFind = JsonNode(data).getAt(toFindPath)!! + assertTrue(toFind is AnyMap && toFind["name"] == "Lando Won") + + val subject = NadelResultTracker() + subject.complete(result) + + // Then + val queryPath = NadelQueryPath(listOf("users", "friends", "user")) + + assertTrue(subject.getResultPath(queryPath, JsonNode(toFind)) == toFindPath) + } + + /** + * Not really a real GraphQL scenario, but ensures we support navigating it. + * + * i.e. Here matrix.x and matrix.y are sometimes arrays, and sometimes objects. + */ + @Test + fun arraysMixedWithObjects() = runTest { + // Given + val result = resultOf( + """ + { + "data": { + "matrix": [ + { + "x": [ + {"value": 10} + ] + }, + { + "y": { + "value": 10 + } + }, + { + "y": [ + {"value": 10} + ] + }, + { + "x": { + "value": 10 + } + }, + { + "x": { + "value": 10 + } + }, + { + "z": { + "val": 10 + } + }, + { + "y": { + "value": 10 + } + } + ] + } + } + """.trimIndent() + ) + + val data = result.getData() + + val subject = NadelResultTracker() + subject.complete(result) + + // Then + JsonNode(data).dfs { path, value -> + if (value is AnyList || value is AnyMap) { + val queryPath = NadelQueryPath( + path + .value + .filterIsInstance() + .map(NadelResultPathSegment.Object::key), + ) + + assertTrue(subject.getResultPath(queryPath, JsonNode(value)) == path) + } + } + } + + /** + * Just tests our [JsonNode.dfs] test implementation. + * + * If that didn't work then all our tests may silently pass. + */ + @Test + fun dfs() = runTest { + val result = resultOf( + """ + { + "data": { + "matrix": [ + { + "x": [ + {"value": 10} + ] + }, + { + "y": { + "value": 10 + } + }, + { + "x": { + "value": 10 + } + }, + { + "x": { + "value": 10 + } + }, + { + "z": { + "val": 10 + } + }, + { + "y": { + "value": 10 + } + } + ] + } + } + """.trimIndent() + ) + + val data = result.getData() + + // When + val visited = mutableListOf>() + JsonNode(data).dfs { path, value -> + visited.add(path to value) + } + + // Then + assertTrue(visited.mapTo(HashSet()) { (path) -> path }.size == visited.size) + + // Code to generate expected + // visited.forEach { (path, value) -> + // val code = path + // .joinToString(prefix = "NadelResultPathBuilder()", postfix = ".build()", separator = "") { + // when (it) { + // is NadelResultPathSegment.Array -> ".add(${it.index})" + // is NadelResultPathSegment.Object -> ".add(${jsonObjectMapper.writeValueAsString(it.key)})" + // } + // } + // // Write twice to escape string + // val serializedValue = jsonObjectMapper.writeValueAsString( + // jsonObjectMapper.writeValueAsString(value), + // ) + // println("$code to \njsonObjectMapper.readValue(${serializedValue}),") + // } + + val expected = listOf( + NadelResultPathBuilder().build() to + jsonObjectMapper.readValue("""{"matrix":[{"x":[{"value":10}]},{"y":{"value":10}},{"x":{"value":10}},{"x":{"value":10}},{"z":{"val":10}},{"y":{"value":10}}]}"""), + NadelResultPathBuilder().add("matrix").build() to + jsonObjectMapper.readValue("""[{"x":[{"value":10}]},{"y":{"value":10}},{"x":{"value":10}},{"x":{"value":10}},{"z":{"val":10}},{"y":{"value":10}}]"""), + NadelResultPathBuilder().add("matrix").add(0).build() to + jsonObjectMapper.readValue("""{"x":[{"value":10}]}"""), + NadelResultPathBuilder().add("matrix").add(0).add("x").build() to + jsonObjectMapper.readValue("""[{"value":10}]"""), + NadelResultPathBuilder().add("matrix").add(0).add("x").add(0).build() to + jsonObjectMapper.readValue("""{"value":10}"""), + NadelResultPathBuilder().add("matrix").add(0).add("x").add(0).add("value").build() to + jsonObjectMapper.readValue("10"), + NadelResultPathBuilder().add("matrix").add(1).build() to + jsonObjectMapper.readValue("""{"y":{"value":10}}"""), + NadelResultPathBuilder().add("matrix").add(1).add("y").build() to + jsonObjectMapper.readValue("""{"value":10}"""), + NadelResultPathBuilder().add("matrix").add(1).add("y").add("value").build() to + jsonObjectMapper.readValue("10"), + NadelResultPathBuilder().add("matrix").add(2).build() to + jsonObjectMapper.readValue("""{"x":{"value":10}}"""), + NadelResultPathBuilder().add("matrix").add(2).add("x").build() to + jsonObjectMapper.readValue("""{"value":10}"""), + NadelResultPathBuilder().add("matrix").add(2).add("x").add("value").build() to + jsonObjectMapper.readValue("10"), + NadelResultPathBuilder().add("matrix").add(3).build() to + jsonObjectMapper.readValue("""{"x":{"value":10}}"""), + NadelResultPathBuilder().add("matrix").add(3).add("x").build() to + jsonObjectMapper.readValue("""{"value":10}"""), + NadelResultPathBuilder().add("matrix").add(3).add("x").add("value").build() to + jsonObjectMapper.readValue("10"), + NadelResultPathBuilder().add("matrix").add(4).build() to + jsonObjectMapper.readValue("""{"z":{"val":10}}"""), + NadelResultPathBuilder().add("matrix").add(4).add("z").build() to + jsonObjectMapper.readValue("""{"val":10}"""), + NadelResultPathBuilder().add("matrix").add(4).add("z").add("val").build() to + jsonObjectMapper.readValue("10"), + NadelResultPathBuilder().add("matrix").add(5).build() to + jsonObjectMapper.readValue("""{"y":{"value":10}}"""), + NadelResultPathBuilder().add("matrix").add(5).add("y").build() to + jsonObjectMapper.readValue("""{"value":10}"""), + NadelResultPathBuilder().add("matrix").add(5).add("y").add("value").build() to + jsonObjectMapper.readValue("10"), + ) + + assertTrue(visited == expected) + } + + private fun resultOf(json: String): ExecutionResult { + val result = jsonObjectMapper.readValue(json) + @Suppress("UNCHECKED_CAST") + return ExecutionResult.newExecutionResult() + .data(result["data"]) + .errors((result["errors"] as List?)?.map(::toGraphQLError)) + .extensions(result["extensions"] as Map?) + .build() + } + + /** + * But why are you testing NadelResultTracker by effectively duplicating the functionality?? + * + * Well the point of NadelResultTracker is that you're finding one node. + * The tricky part is creating one result node path and reusing it as we're traversing. + * It's much easier to reason the logic in this recursive function. + */ + private suspend fun JsonNode.dfs( + path: NadelResultPath = NadelResultPath.empty, + onConsume: suspend (NadelResultPath, Any?) -> Unit, + ) { + onConsume(path, value) + + when (val value = value) { + is AnyMap -> value.forEach { (key, element) -> + assertTrue(key is String) + JsonNode(element).dfs(path.toBuilder().add(key).build(), onConsume) + } + is AnyList -> value.forEachIndexed { index, element -> + JsonNode(element).dfs(path.toBuilder().add(index).build(), onConsume) + } + } + } + + private fun JsonNode.getAt( + path: NadelResultPath, + ): Any? { + return getAt(path.value) + } + + private fun JsonNode.getAt( + path: List, + ): Any? { + return path.foldWhileNotNull(value) { prev, pathSegment -> + when (pathSegment) { + is NadelResultPathSegment.Array -> (prev as AnyList)[pathSegment.index] + is NadelResultPathSegment.Object -> (prev as AnyMap)[pathSegment.key] + } + } + } +} diff --git a/test/src/test/kotlin/graphql/nadel/tests/EngineTests.kt b/test/src/test/kotlin/graphql/nadel/tests/EngineTests.kt index 058e4b3e0..5043c9b47 100644 --- a/test/src/test/kotlin/graphql/nadel/tests/EngineTests.kt +++ b/test/src/test/kotlin/graphql/nadel/tests/EngineTests.kt @@ -349,8 +349,8 @@ private suspend fun execute( val expectedResponse = fixture.response if (expectedResponse != null) { // TODO: check extensions one day - right now they don't match up as dumped tests weren't fully E2E but tests are - assertJsonObject( - subject = response.toSpecification().let { + assertJsonEquals( + actual = response.toSpecification().let { mapOf( "data" to it["data"], "errors" to (it["errors"] ?: emptyList()), diff --git a/test/src/test/kotlin/graphql/nadel/tests/JsonAssertions.kt b/test/src/test/kotlin/graphql/nadel/tests/JsonAssertions.kt index 71b62f319..d46311bc2 100644 --- a/test/src/test/kotlin/graphql/nadel/tests/JsonAssertions.kt +++ b/test/src/test/kotlin/graphql/nadel/tests/JsonAssertions.kt @@ -5,15 +5,9 @@ import graphql.nadel.engine.util.AnyMap import graphql.nadel.engine.util.JsonMap import graphql.nadel.tests.util.keysEqual import strikt.api.Assertion -import strikt.api.expectThat import strikt.assertions.isA -fun assertJsonObject(subject: JsonMap, expected: JsonMap) { - return expectThat(subject) { - assertJsonObject(expectedMap = expected) - } -} - +@Deprecated("Do not use") fun Assertion.Builder.assertJsonKeys(): Assertion.Builder { assert("keys are all strings") { subject -> @Suppress("UNCHECKED_CAST") // We're checking if the erased type holds up @@ -30,6 +24,7 @@ fun Assertion.Builder.assertJsonKeys(): Assertion.Builder { return this as Assertion.Builder } +@Deprecated("Do not use") private fun Assertion.Builder.assertJsonObject(expectedMap: JsonMap) { keysEqual(expectedMap.keys) @@ -44,11 +39,13 @@ private fun Assertion.Builder.assertJsonObject(expectedMap: JsonMap) { } } +@Deprecated("Do not use") private fun Assertion.Builder.assertJsonEntry(key: String, subjectValue: Any?, expectedValue: Any?) { get("""entry "$key"""") { subjectValue } .assertJsonValue(subjectValue, expectedValue) } +@Deprecated("Do not use") private fun jsonTypeOf(element: Any?): String { return when (element) { is AnyList -> "JSON array" @@ -64,6 +61,7 @@ private fun jsonTypeOf(element: Any?): String { } } +@Deprecated("Do not use") private fun Assertion.Builder.assertJsonValue(subjectValue: Any?, expectedValue: Any?) { when (subjectValue) { is AnyMap -> { @@ -100,6 +98,7 @@ private fun Assertion.Builder.assertJsonValue(subjectValue: Any?, expecte } } +@Deprecated("Do not use") private fun Assertion.Builder>.assertJsonArray(expectedValue: List) { compose("all elements match expected:") { subject -> assert("size matches expected") { diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/IncrementalResultJoiner.kt b/test/src/test/kotlin/graphql/nadel/tests/next/CombineExecutionResults.kt similarity index 88% rename from test/src/test/kotlin/graphql/nadel/tests/next/IncrementalResultJoiner.kt rename to test/src/test/kotlin/graphql/nadel/tests/next/CombineExecutionResults.kt index 1bbb0c565..7caf9e035 100644 --- a/test/src/test/kotlin/graphql/nadel/tests/next/IncrementalResultJoiner.kt +++ b/test/src/test/kotlin/graphql/nadel/tests/next/CombineExecutionResults.kt @@ -12,7 +12,7 @@ fun combineExecutionResults(result: JsonMap, incrementalResults: List): val resultData = deepClone["data"] as JsonMap? @Suppress("UNCHECKED_CAST") // Ok I wanted MutableList<*> but seems like that doesn't work for some reason… - val resultErrors: MutableList = deepClone["errors"] as MutableList? ?: mutableListOf() + val resultErrors = deepClone["errors"] as MutableList? ?: mutableListOf() if (resultData != null) { incrementalResults @@ -29,12 +29,15 @@ fun combineExecutionResults(result: JsonMap, incrementalResults: List): when { "data" in payload -> { - val data = payload["data"]!! - setDeferred(resultData, path, data) + val data = payload["data"] + if (data != null) { + setDeferred(resultData, path, data) + } } "items" in payload -> { throw UnsupportedOperationException("Merging @stream results is not supported yet") } + else -> throw UnsupportedOperationException() } val elements: List = (payload["errors"] as List<*>?) ?: emptyList() diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferForwardsErrors.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferForwardsErrors.kt new file mode 100644 index 000000000..b7c7225f8 --- /dev/null +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferForwardsErrors.kt @@ -0,0 +1,212 @@ +package graphql.nadel.tests.next.fixtures.hydration.defer + +import graphql.ExecutionResult +import graphql.incremental.DelayedIncrementalPartialResult +import graphql.incremental.IncrementalExecutionResult +import graphql.nadel.NadelExecutionHints +import graphql.nadel.engine.util.strictAssociateBy +import graphql.nadel.error.NadelGraphQLErrorException +import graphql.nadel.tests.next.NadelIntegrationTest +import org.intellij.lang.annotations.Language +import kotlin.test.assertTrue + +class HydrationDeferForwardsErrorsTest : HydrationDeferForwardsErrors( + query = """ + query { + issueByKey(key: "GQLGW-2") { + ... @defer { + assignee { + name + } + } + } + } + """.trimIndent(), +) { + override fun assert(result: ExecutionResult, incrementalResults: List?) { + assertTrue(result is IncrementalExecutionResult) + assertTrue(incrementalResults?.isNotEmpty() == true) + assertTrue(incrementalResults?.single()?.incremental?.single()?.errors?.isNotEmpty() == true) + } +} + +class HydrationDeferForwardsErrorsFromEachHydrationTest : HydrationDeferForwardsErrors( + query = """ + query { + issuesByKeys(keys: ["GQLGW-2", "GQLGW-3", "GQLGW-4"]) { + key + ... @defer { + assignee { + name + } + } + } + } + """.trimIndent(), +) { + override fun assert(result: ExecutionResult, incrementalResults: List?) { + assertTrue(result is IncrementalExecutionResult) + assertTrue(incrementalResults?.isNotEmpty() == true) + } +} + +abstract class HydrationDeferForwardsErrors( + @Language("GraphQL") + query: String, +) : NadelIntegrationTest( + query = query, + services = listOf( + Service( + name = "issues", + overallSchema = """ + directive @defer(if: Boolean, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT + type Query { + issues: [Issue!] + issuesByKeys(keys: [String!]!): [Issue!] + issueGroups: [[Issue]] + issueByKey(key: String!): Issue + } + type Issue { + key: String! + assigneeId: ID! + self: Issue + @hydrated( + service: "issues" + field: "issueByKey" + arguments: [{name: "key", value: "$source.key"}] + ) + assignee: User + @hydrated( + service: "users" + field: "userById" + arguments: [{name: "id", value: "$source.assigneeId"}] + ) + related: [Issue!] + parent: Issue + } + """.trimIndent(), + runtimeWiring = { wiring -> + data class Issue( + val key: String, + val assigneeId: String, + val parentKey: String? = null, + val relatedKeys: List = emptyList(), + ) + + val issues = listOf( + Issue( + key = "GQLGW-1", + assigneeId = "ari:cloud:identity::user/1", + ), + Issue( + key = "GQLGW-2", + assigneeId = "ari:cloud:identity::user/0", + parentKey = "GQLGW-1", + relatedKeys = listOf("GQLGW-1"), + ), + Issue( + key = "GQLGW-3", + assigneeId = "ari:cloud:identity::user/1", + parentKey = "GQLGW-1", + relatedKeys = listOf("GQLGW-1", "GQLGW-2"), + ), + Issue( + key = "GQLGW-4", + assigneeId = "ari:cloud:identity::user/10", + parentKey = "GQLGW-1", + relatedKeys = listOf("GQLGW-1", "GQLGW-2", "GQLGW-3"), + ), + ) + val issuesByKey = issues.strictAssociateBy { it.key } + + wiring + .type("Query") { type -> + type + .dataFetcher("issueByKey") { env -> + issuesByKey[env.getArgument("key")] + } + .dataFetcher("issuesByKeys") { env -> + val keys = env.getArgument>("keys")!!.toSet() + issues + .filter { + it.key in keys + } + } + .dataFetcher("issues") { env -> + issues + } + .dataFetcher("issueGroups") { + issues + .groupBy { + it.key.substringAfter("-").toInt() % 2 == 0 + } + .values + } + } + .type("Issue") { type -> + type + .dataFetcher("related") { env -> + env.getSource()!! + .relatedKeys + .map { + issuesByKey[it]!! + } + } + .dataFetcher("parent") { env -> + issuesByKey[env.getSource()!!.parentKey] + } + } + }, + ), + Service( + name = "users", + overallSchema = """ + type Query { + userById(id: ID!): User + } + type User { + id: ID! + name: String! + } + """.trimIndent(), + runtimeWiring = { wiring -> + data class User( + val id: String, + val name: String, + ) + + val users = listOf( + User( + id = "ari:cloud:identity::user/1", + name = "Frank", + ), + User( + id = "ari:cloud:identity::user/2", + name = "Tom", + ), + User( + id = "ari:cloud:identity::user/3", + name = "Lin", + ), + ) + val usersById = users.strictAssociateBy { it.id } + + class UserNotFoundError(id: String) : NadelGraphQLErrorException(message = "No user: $id") + + wiring + .type("Query") { type -> + type + .dataFetcher("userById") { env -> + val id = env.getArgument("id")!! + usersById[id] ?: throw UserNotFoundError(id) + } + } + }, + ), + ), +) { + override fun makeExecutionHints(): NadelExecutionHints.Builder { + return super.makeExecutionHints() + .deferSupport { true } + } +} diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferForwardsErrorsFromEachHydrationTestSnapshot.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferForwardsErrorsFromEachHydrationTestSnapshot.kt new file mode 100644 index 000000000..c4249ba9c --- /dev/null +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferForwardsErrorsFromEachHydrationTestSnapshot.kt @@ -0,0 +1,278 @@ +// @formatter:off +package graphql.nadel.tests.next.fixtures.hydration.defer + +import graphql.nadel.tests.next.ExpectedNadelResult +import graphql.nadel.tests.next.ExpectedServiceCall +import graphql.nadel.tests.next.TestSnapshot +import graphql.nadel.tests.next.listOfJsonStrings +import kotlin.Suppress +import kotlin.collections.List +import kotlin.collections.listOf + +private suspend fun main() { + graphql.nadel.tests.next.update() +} + +/** + * This class is generated. Do NOT modify. + * + * Refer to [graphql.nadel.tests.next.UpdateTestSnapshots + */ +@Suppress("unused") +public class HydrationDeferForwardsErrorsFromEachHydrationTestSnapshot : TestSnapshot() { + override val calls: List = listOf( + ExpectedServiceCall( + service = "issues", + query = """ + | { + | issuesByKeys(keys: ["GQLGW-2", "GQLGW-3", "GQLGW-4"]) { + | key + | hydration__assignee__assigneeId: assigneeId + | __typename__hydration__assignee: __typename + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "issuesByKeys": [ + | { + | "key": "GQLGW-2", + | "hydration__assignee__assigneeId": "ari:cloud:identity::user/0", + | "__typename__hydration__assignee": "Issue" + | }, + | { + | "key": "GQLGW-3", + | "hydration__assignee__assigneeId": "ari:cloud:identity::user/1", + | "__typename__hydration__assignee": "Issue" + | }, + | { + | "key": "GQLGW-4", + | "hydration__assignee__assigneeId": "ari:cloud:identity::user/10", + | "__typename__hydration__assignee": "Issue" + | } + | ] + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ExpectedServiceCall( + service = "users", + query = """ + | { + | userById(id: "ari:cloud:identity::user/0") { + | name + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "errors": [ + | { + | "message": "No user: ari:cloud:identity::user/0", + | "extensions": { + | "classification": "UserNotFoundError" + | } + | } + | ], + | "data": { + | "userById": null + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ExpectedServiceCall( + service = "users", + query = """ + | { + | userById(id: "ari:cloud:identity::user/1") { + | name + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "userById": { + | "name": "Frank" + | } + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ExpectedServiceCall( + service = "users", + query = """ + | { + | userById(id: "ari:cloud:identity::user/10") { + | name + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "errors": [ + | { + | "message": "No user: ari:cloud:identity::user/10", + | "extensions": { + | "classification": "UserNotFoundError" + | } + | } + | ], + | "data": { + | "userById": null + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ) + + /** + * ```json + * { + * "data": { + * "issuesByKeys": [ + * { + * "key": "GQLGW-2", + * "assignee": null + * }, + * { + * "key": "GQLGW-3", + * "assignee": { + * "name": "Frank" + * } + * }, + * { + * "key": "GQLGW-4", + * "assignee": null + * } + * ] + * }, + * "errors": [ + * { + * "message": "No user: ari:cloud:identity::user/0", + * "locations": [], + * "path": [ + * "issuesByKeys", + * 0, + * "assignee" + * ], + * "extensions": { + * "classification": "UserNotFoundError" + * } + * }, + * { + * "message": "No user: ari:cloud:identity::user/10", + * "locations": [], + * "path": [ + * "issuesByKeys", + * 2, + * "assignee" + * ], + * "extensions": { + * "classification": "UserNotFoundError" + * } + * } + * ] + * } + * ``` + */ + override val result: ExpectedNadelResult = ExpectedNadelResult( + result = """ + | { + | "data": { + | "issuesByKeys": [ + | { + | "key": "GQLGW-2" + | }, + | { + | "key": "GQLGW-3" + | }, + | { + | "key": "GQLGW-4" + | } + | ] + | }, + | "hasNext": true + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + """ + | { + | "hasNext": false, + | "incremental": [ + | { + | "path": [ + | "issuesByKeys", + | 0 + | ], + | "errors": [ + | { + | "message": "No user: ari:cloud:identity::user/0", + | "locations": [], + | "path": [ + | "issuesByKeys", + | 0, + | "assignee" + | ], + | "extensions": { + | "classification": "UserNotFoundError" + | } + | } + | ], + | "data": { + | "assignee": null + | } + | }, + | { + | "path": [ + | "issuesByKeys", + | 1 + | ], + | "data": { + | "assignee": { + | "name": "Frank" + | } + | } + | }, + | { + | "path": [ + | "issuesByKeys", + | 2 + | ], + | "errors": [ + | { + | "message": "No user: ari:cloud:identity::user/10", + | "locations": [], + | "path": [ + | "issuesByKeys", + | 2, + | "assignee" + | ], + | "extensions": { + | "classification": "UserNotFoundError" + | } + | } + | ], + | "data": { + | "assignee": null + | } + | } + | ] + | } + """.trimMargin(), + ), + ) +} diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferForwardsErrorsTestSnapshot.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferForwardsErrorsTestSnapshot.kt new file mode 100644 index 000000000..1f9a55230 --- /dev/null +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferForwardsErrorsTestSnapshot.kt @@ -0,0 +1,142 @@ +// @formatter:off +package graphql.nadel.tests.next.fixtures.hydration.defer + +import graphql.nadel.tests.next.ExpectedNadelResult +import graphql.nadel.tests.next.ExpectedServiceCall +import graphql.nadel.tests.next.TestSnapshot +import graphql.nadel.tests.next.listOfJsonStrings +import kotlin.Suppress +import kotlin.collections.List +import kotlin.collections.listOf + +private suspend fun main() { + graphql.nadel.tests.next.update() +} + +/** + * This class is generated. Do NOT modify. + * + * Refer to [graphql.nadel.tests.next.UpdateTestSnapshots + */ +@Suppress("unused") +public class HydrationDeferForwardsErrorsTestSnapshot : TestSnapshot() { + override val calls: List = listOf( + ExpectedServiceCall( + service = "issues", + query = """ + | { + | issueByKey(key: "GQLGW-2") { + | hydration__assignee__assigneeId: assigneeId + | __typename__hydration__assignee: __typename + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "issueByKey": { + | "hydration__assignee__assigneeId": "ari:cloud:identity::user/0", + | "__typename__hydration__assignee": "Issue" + | } + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ExpectedServiceCall( + service = "users", + query = """ + | { + | userById(id: "ari:cloud:identity::user/0") { + | name + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "errors": [ + | { + | "message": "No user: ari:cloud:identity::user/0", + | "extensions": { + | "classification": "UserNotFoundError" + | } + | } + | ], + | "data": { + | "userById": null + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ) + + /** + * ```json + * { + * "data": { + * "issueByKey": { + * "assignee": null + * } + * }, + * "errors": [ + * { + * "message": "No user: ari:cloud:identity::user/0", + * "locations": [], + * "path": [ + * "issueByKey", + * "assignee" + * ], + * "extensions": { + * "classification": "UserNotFoundError" + * } + * } + * ] + * } + * ``` + */ + override val result: ExpectedNadelResult = ExpectedNadelResult( + result = """ + | { + | "data": { + | "issueByKey": {} + | }, + | "hasNext": true + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + """ + | { + | "hasNext": false, + | "incremental": [ + | { + | "path": [ + | "issueByKey" + | ], + | "errors": [ + | { + | "message": "No user: ari:cloud:identity::user/0", + | "locations": [], + | "path": [ + | "issueByKey", + | "assignee" + | ], + | "extensions": { + | "classification": "UserNotFoundError" + | } + | } + | ], + | "data": { + | "assignee": null + | } + | } + | ] + | } + """.trimMargin(), + ), + ) +} diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInList.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInList.kt new file mode 100644 index 000000000..a0ca1aa8c --- /dev/null +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInList.kt @@ -0,0 +1,227 @@ +package graphql.nadel.tests.next.fixtures.hydration.defer + +import graphql.nadel.NadelExecutionHints +import graphql.nadel.engine.util.strictAssociateBy +import graphql.nadel.tests.next.NadelIntegrationTest +import org.intellij.lang.annotations.Language + +class HydrationDeferInListTest : HydrationDeferInList( + query = """ + query { + issueByKey(key: "GQLGW-2") { # Not a list + key + ... @defer { + assignee { + name + } + } + related { # Is a list + ... @defer { + assignee { + name + } + } + } + } + } + """.trimIndent(), +) + +class HydrationDeferInListTwoDimensionsTest : HydrationDeferInList( + query = """ + query { + issueGroups { + key + ... @defer { + assignee { + name + } + } + } + } + """.trimIndent(), +) + +class HydrationDeferInListTopLevelTest : HydrationDeferInList( + query = """ + query { + issues { # List + key + ... @defer { + assignee { + name + } + } + } + } + """.trimIndent(), +) + +class HydrationDeferInListNestedTest : HydrationDeferInList( + query = """ + query { + issueByKey(key: "GQLGW-3") { # Not a list + key + related { # Is a list + parent { # Not a list + ... @defer { + assignee { + name + } + } + } + } + } + } + """.trimIndent(), +) + +abstract class HydrationDeferInList( + @Language("GraphQL") + query: String, +) : NadelIntegrationTest( + query = query, + services = listOf( + Service( + name = "issues", + overallSchema = """ + directive @defer(if: Boolean, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT + type Query { + issues: [Issue!] + issueGroups: [[Issue]] + issueByKey(key: String!): Issue + } + type Issue { + key: String! + assigneeId: ID! + self: Issue + @hydrated( + service: "issues" + field: "issueByKey" + arguments: [{name: "key", value: "$source.key"}] + ) + assignee: User + @hydrated( + service: "users" + field: "userById" + arguments: [{name: "id", value: "$source.assigneeId"}] + ) + related: [Issue!] + parent: Issue + } + """.trimIndent(), + runtimeWiring = { wiring -> + data class Issue( + val key: String, + val assigneeId: String, + val parentKey: String? = null, + val relatedKeys: List = emptyList(), + ) + + val issues = listOf( + Issue( + key = "GQLGW-1", + assigneeId = "ari:cloud:identity::user/1", + ), + Issue( + key = "GQLGW-2", + assigneeId = "ari:cloud:identity::user/2", + parentKey = "GQLGW-1", + relatedKeys = listOf("GQLGW-1"), + ), + Issue( + key = "GQLGW-3", + assigneeId = "ari:cloud:identity::user/1", + parentKey = "GQLGW-1", + relatedKeys = listOf("GQLGW-1", "GQLGW-2"), + ), + Issue( + key = "GQLGW-4", + assigneeId = "ari:cloud:identity::user/3", + parentKey = "GQLGW-1", + relatedKeys = listOf("GQLGW-1", "GQLGW-2", "GQLGW-3"), + ), + ) + val issuesByKey = issues.strictAssociateBy { it.key } + + wiring + .type("Query") { type -> + type + .dataFetcher("issueByKey") { env -> + issuesByKey[env.getArgument("key")] + } + .dataFetcher("issues") { env -> + issues + } + .dataFetcher("issueGroups") { + issues + .groupBy { + it.key.substringAfter("-").toInt() % 2 == 0 + } + .values + } + } + .type("Issue") { type -> + type + .dataFetcher("related") { env -> + env.getSource()!! + .relatedKeys + .map { + issuesByKey[it]!! + } + } + .dataFetcher("parent") { env -> + issuesByKey[env.getSource()!!.parentKey] + } + } + }, + ), + Service( + name = "users", + overallSchema = """ + type Query { + userById(id: ID!): User + } + type User { + id: ID! + name: String! + } + """.trimIndent(), + runtimeWiring = { wiring -> + data class User( + val id: String, + val name: String, + ) + + val users = listOf( + User( + id = "ari:cloud:identity::user/1", + name = "Frank", + ), + User( + id = "ari:cloud:identity::user/2", + name = "Tom", + ), + User( + id = "ari:cloud:identity::user/3", + name = "Lin", + ), + ) + val usersById = users.strictAssociateBy { it.id } + + wiring + .type("Query") { type -> + type + .dataFetcher("userById") { env -> + usersById[env.getArgument("id")] + } + } + }, + ), + ), +) { + override fun makeExecutionHints(): NadelExecutionHints.Builder { + return super.makeExecutionHints() + .deferSupport { true } + } +} diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabledInListOfRelatedIssuesForParentIssueTestSnapshot.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListNestedTestSnapshot.kt similarity index 79% rename from test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabledInListOfRelatedIssuesForParentIssueTestSnapshot.kt rename to test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListNestedTestSnapshot.kt index bec6840db..455e34e81 100644 --- a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabledInListOfRelatedIssuesForParentIssueTestSnapshot.kt +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListNestedTestSnapshot.kt @@ -10,7 +10,7 @@ import kotlin.collections.List import kotlin.collections.listOf private suspend fun main() { - graphql.nadel.tests.next.update() + graphql.nadel.tests.next.update() } /** @@ -19,8 +19,7 @@ private suspend fun main() { * Refer to [graphql.nadel.tests.next.UpdateTestSnapshots */ @Suppress("unused") -public class HydrationDeferIsDisabledInListOfRelatedIssuesForParentIssueTestSnapshot : - TestSnapshot() { +public class HydrationDeferInListNestedTestSnapshot : TestSnapshot() { override val calls: List = listOf( ExpectedServiceCall( service = "issues", @@ -75,7 +74,7 @@ public class HydrationDeferIsDisabledInListOfRelatedIssuesForParentIssueTestSnap | { | "data": { | "userById": { - | "name": "Franklin" + | "name": "Frank" | } | } | } @@ -98,7 +97,7 @@ public class HydrationDeferIsDisabledInListOfRelatedIssuesForParentIssueTestSnap * { * "parent": { * "assignee": { - * "name": "Franklin" + * "name": "Frank" * } * } * } @@ -119,18 +118,35 @@ public class HydrationDeferIsDisabledInListOfRelatedIssuesForParentIssueTestSnap | "parent": null | }, | { - | "parent": { - | "assignee": { - | "name": "Franklin" - | } - | } + | "parent": {} | } | ] | } - | } + | }, + | "hasNext": true | } """.trimMargin(), delayedResults = listOfJsonStrings( + """ + | { + | "hasNext": false, + | "incremental": [ + | { + | "path": [ + | "issueByKey", + | "related", + | 1, + | "parent" + | ], + | "data": { + | "assignee": { + | "name": "Frank" + | } + | } + | } + | ] + | } + """.trimMargin(), ), ) } diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabledForRelatedIssuesTestSnapshot.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListTestSnapshot.kt similarity index 85% rename from test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabledForRelatedIssuesTestSnapshot.kt rename to test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListTestSnapshot.kt index 530bdd4d7..dc29d74f7 100644 --- a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabledForRelatedIssuesTestSnapshot.kt +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListTestSnapshot.kt @@ -10,7 +10,7 @@ import kotlin.collections.List import kotlin.collections.listOf private suspend fun main() { - graphql.nadel.tests.next.update() + graphql.nadel.tests.next.update() } /** @@ -19,7 +19,7 @@ private suspend fun main() { * Refer to [graphql.nadel.tests.next.UpdateTestSnapshots */ @Suppress("unused") -public class HydrationDeferIsDisabledForRelatedIssuesTestSnapshot : TestSnapshot() { +public class HydrationDeferInListTestSnapshot : TestSnapshot() { override val calls: List = listOf( ExpectedServiceCall( service = "issues", @@ -71,7 +71,7 @@ public class HydrationDeferIsDisabledForRelatedIssuesTestSnapshot : TestSnapshot | { | "data": { | "userById": { - | "name": "Franklin" + | "name": "Frank" | } | } | } @@ -112,7 +112,7 @@ public class HydrationDeferIsDisabledForRelatedIssuesTestSnapshot : TestSnapshot * "related": [ * { * "assignee": { - * "name": "Franklin" + * "name": "Frank" * } * } * ], @@ -131,11 +131,7 @@ public class HydrationDeferIsDisabledForRelatedIssuesTestSnapshot : TestSnapshot | "issueByKey": { | "key": "GQLGW-2", | "related": [ - | { - | "assignee": { - | "name": "Franklin" - | } - | } + | {} | ] | } | }, @@ -149,6 +145,25 @@ public class HydrationDeferIsDisabledForRelatedIssuesTestSnapshot : TestSnapshot | "incremental": [ | { | "path": [ + | "issueByKey", + | "related", + | 0 + | ], + | "data": { + | "assignee": { + | "name": "Frank" + | } + | } + | } + | ] + | } + """.trimMargin(), + """ + | { + | "hasNext": true, + | "incremental": [ + | { + | "path": [ | "issueByKey" | ], | "data": { diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabledTestSnapshot.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListTopLevelTestSnapshot.kt similarity index 60% rename from test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabledTestSnapshot.kt rename to test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListTopLevelTestSnapshot.kt index f8ba4a777..2f0f153cb 100644 --- a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabledTestSnapshot.kt +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListTopLevelTestSnapshot.kt @@ -10,7 +10,7 @@ import kotlin.collections.List import kotlin.collections.listOf private suspend fun main() { - graphql.nadel.tests.next.update() + graphql.nadel.tests.next.update() } /** @@ -19,7 +19,7 @@ private suspend fun main() { * Refer to [graphql.nadel.tests.next.UpdateTestSnapshots */ @Suppress("unused") -public class HydrationDeferIsDisabledTestSnapshot : TestSnapshot() { +public class HydrationDeferInListTopLevelTestSnapshot : TestSnapshot() { override val calls: List = listOf( ExpectedServiceCall( service = "issues", @@ -51,6 +51,11 @@ public class HydrationDeferIsDisabledTestSnapshot : TestSnapshot() { | "key": "GQLGW-3", | "hydration__assignee__assigneeId": "ari:cloud:identity::user/1", | "__typename__hydration__assignee": "Issue" + | }, + | { + | "key": "GQLGW-4", + | "hydration__assignee__assigneeId": "ari:cloud:identity::user/3", + | "__typename__hydration__assignee": "Issue" | } | ] | } @@ -73,7 +78,7 @@ public class HydrationDeferIsDisabledTestSnapshot : TestSnapshot() { | { | "data": { | "userById": { - | "name": "Franklin" + | "name": "Frank" | } | } | } @@ -95,7 +100,7 @@ public class HydrationDeferIsDisabledTestSnapshot : TestSnapshot() { | { | "data": { | "userById": { - | "name": "Franklin" + | "name": "Frank" | } | } | } @@ -125,6 +130,28 @@ public class HydrationDeferIsDisabledTestSnapshot : TestSnapshot() { delayedResults = listOfJsonStrings( ), ), + ExpectedServiceCall( + service = "users", + query = """ + | { + | userById(id: "ari:cloud:identity::user/3") { + | name + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "userById": { + | "name": "Lin" + | } + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), ) /** @@ -135,7 +162,7 @@ public class HydrationDeferIsDisabledTestSnapshot : TestSnapshot() { * { * "key": "GQLGW-1", * "assignee": { - * "name": "Franklin" + * "name": "Frank" * } * }, * { @@ -147,7 +174,13 @@ public class HydrationDeferIsDisabledTestSnapshot : TestSnapshot() { * { * "key": "GQLGW-3", * "assignee": { - * "name": "Franklin" + * "name": "Frank" + * } + * }, + * { + * "key": "GQLGW-4", + * "assignee": { + * "name": "Lin" * } * } * ] @@ -161,28 +194,74 @@ public class HydrationDeferIsDisabledTestSnapshot : TestSnapshot() { | "data": { | "issues": [ | { - | "key": "GQLGW-1", - | "assignee": { - | "name": "Franklin" - | } + | "key": "GQLGW-1" + | }, + | { + | "key": "GQLGW-2" | }, | { - | "key": "GQLGW-2", - | "assignee": { - | "name": "Tom" - | } + | "key": "GQLGW-3" | }, | { - | "key": "GQLGW-3", - | "assignee": { - | "name": "Franklin" - | } + | "key": "GQLGW-4" | } | ] - | } + | }, + | "hasNext": true | } """.trimMargin(), delayedResults = listOfJsonStrings( + """ + | { + | "hasNext": false, + | "incremental": [ + | { + | "path": [ + | "issues", + | 0 + | ], + | "data": { + | "assignee": { + | "name": "Frank" + | } + | } + | }, + | { + | "path": [ + | "issues", + | 1 + | ], + | "data": { + | "assignee": { + | "name": "Tom" + | } + | } + | }, + | { + | "path": [ + | "issues", + | 2 + | ], + | "data": { + | "assignee": { + | "name": "Frank" + | } + | } + | }, + | { + | "path": [ + | "issues", + | 3 + | ], + | "data": { + | "assignee": { + | "name": "Lin" + | } + | } + | } + | ] + | } + """.trimMargin(), ), ) } diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListTwoDimensionsTestSnapshot.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListTwoDimensionsTestSnapshot.kt new file mode 100644 index 000000000..07ab0ce2c --- /dev/null +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferInListTwoDimensionsTestSnapshot.kt @@ -0,0 +1,283 @@ +// @formatter:off +package graphql.nadel.tests.next.fixtures.hydration.defer + +import graphql.nadel.tests.next.ExpectedNadelResult +import graphql.nadel.tests.next.ExpectedServiceCall +import graphql.nadel.tests.next.TestSnapshot +import graphql.nadel.tests.next.listOfJsonStrings +import kotlin.Suppress +import kotlin.collections.List +import kotlin.collections.listOf + +private suspend fun main() { + graphql.nadel.tests.next.update() +} + +/** + * This class is generated. Do NOT modify. + * + * Refer to [graphql.nadel.tests.next.UpdateTestSnapshots + */ +@Suppress("unused") +public class HydrationDeferInListTwoDimensionsTestSnapshot : TestSnapshot() { + override val calls: List = listOf( + ExpectedServiceCall( + service = "issues", + query = """ + | { + | issueGroups { + | key + | hydration__assignee__assigneeId: assigneeId + | __typename__hydration__assignee: __typename + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "issueGroups": [ + | [ + | { + | "key": "GQLGW-1", + | "hydration__assignee__assigneeId": "ari:cloud:identity::user/1", + | "__typename__hydration__assignee": "Issue" + | }, + | { + | "key": "GQLGW-3", + | "hydration__assignee__assigneeId": "ari:cloud:identity::user/1", + | "__typename__hydration__assignee": "Issue" + | } + | ], + | [ + | { + | "key": "GQLGW-2", + | "hydration__assignee__assigneeId": "ari:cloud:identity::user/2", + | "__typename__hydration__assignee": "Issue" + | }, + | { + | "key": "GQLGW-4", + | "hydration__assignee__assigneeId": "ari:cloud:identity::user/3", + | "__typename__hydration__assignee": "Issue" + | } + | ] + | ] + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ExpectedServiceCall( + service = "users", + query = """ + | { + | userById(id: "ari:cloud:identity::user/1") { + | name + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "userById": { + | "name": "Frank" + | } + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ExpectedServiceCall( + service = "users", + query = """ + | { + | userById(id: "ari:cloud:identity::user/1") { + | name + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "userById": { + | "name": "Frank" + | } + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ExpectedServiceCall( + service = "users", + query = """ + | { + | userById(id: "ari:cloud:identity::user/2") { + | name + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "userById": { + | "name": "Tom" + | } + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ExpectedServiceCall( + service = "users", + query = """ + | { + | userById(id: "ari:cloud:identity::user/3") { + | name + | } + | } + """.trimMargin(), + variables = "{}", + result = """ + | { + | "data": { + | "userById": { + | "name": "Lin" + | } + | } + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + ), + ), + ) + + /** + * ```json + * { + * "data": { + * "issueGroups": [ + * [ + * { + * "key": "GQLGW-1", + * "assignee": { + * "name": "Frank" + * } + * }, + * { + * "key": "GQLGW-3", + * "assignee": { + * "name": "Frank" + * } + * } + * ], + * [ + * { + * "key": "GQLGW-2", + * "assignee": { + * "name": "Tom" + * } + * }, + * { + * "key": "GQLGW-4", + * "assignee": { + * "name": "Lin" + * } + * } + * ] + * ] + * } + * } + * ``` + */ + override val result: ExpectedNadelResult = ExpectedNadelResult( + result = """ + | { + | "data": { + | "issueGroups": [ + | [ + | { + | "key": "GQLGW-1" + | }, + | { + | "key": "GQLGW-3" + | } + | ], + | [ + | { + | "key": "GQLGW-2" + | }, + | { + | "key": "GQLGW-4" + | } + | ] + | ] + | }, + | "hasNext": true + | } + """.trimMargin(), + delayedResults = listOfJsonStrings( + """ + | { + | "hasNext": false, + | "incremental": [ + | { + | "path": [ + | "issueGroups", + | 0, + | 0 + | ], + | "data": { + | "assignee": { + | "name": "Frank" + | } + | } + | }, + | { + | "path": [ + | "issueGroups", + | 0, + | 1 + | ], + | "data": { + | "assignee": { + | "name": "Frank" + | } + | } + | }, + | { + | "path": [ + | "issueGroups", + | 1, + | 0 + | ], + | "data": { + | "assignee": { + | "name": "Tom" + | } + | } + | }, + | { + | "path": [ + | "issueGroups", + | 1, + | 1 + | ], + | "data": { + | "assignee": { + | "name": "Lin" + | } + | } + | } + | ] + | } + """.trimMargin(), + ), + ) +} diff --git a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabled.kt b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabled.kt index 28eeb316f..d22bb4919 100644 --- a/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabled.kt +++ b/test/src/test/kotlin/graphql/nadel/tests/next/fixtures/hydration/defer/HydrationDeferIsDisabled.kt @@ -9,77 +9,6 @@ import graphql.nadel.tests.next.NadelIntegrationTest import org.intellij.lang.annotations.Language import kotlin.test.assertTrue -class HydrationDeferIsDisabledTest : HydrationDeferIsDisabled( - query = """ - query { - issues { # List - key - ... @defer { - assignee { # Should not defer - name - } - } - } - } - """.trimIndent(), -) { - override fun assert(result: ExecutionResult, incrementalResults: List?) { - assertTrue(result !is IncrementalExecutionResult) - } -} - -/** - * There's actually two hydrations here. - * - * There's one hydration at `issueByKey.assignee` which is fine because there's no List. - * - * Then there's the hydration at `issueByKey.related.assignee` which does not defer because `Issue.related` is a List. - */ -class HydrationDeferIsDisabledForRelatedIssuesTest : HydrationDeferIsDisabled( - query = """ - query { - issueByKey(key: "GQLGW-2") { # Not a list - key - ... @defer { - assignee { # Should defer - name - } - } - related { # Is a list - ... @defer { - assignee { # Should NOT defer - name - } - } - } - } - } - """.trimIndent(), -) - -class HydrationDeferIsDisabledInListOfRelatedIssuesForParentIssueTest : HydrationDeferIsDisabled( - query = """ - query { - issueByKey(key: "GQLGW-3") { # Not a list - key - related { # Is a list - parent { # Not a list - ... @defer { - assignee { # Should NOT defer - name - } - } - } - } - } - } - """.trimIndent(), -) { - override fun assert(result: ExecutionResult, incrementalResults: List?) { - assertTrue(result !is IncrementalExecutionResult) - } -} - class HydrationDeferIsDisabledForNestedHydrationsTest : HydrationDeferIsDisabled( query = """ query { @@ -98,6 +27,7 @@ class HydrationDeferIsDisabledForNestedHydrationsTest : HydrationDeferIsDisabled ) { override fun assert(result: ExecutionResult, incrementalResults: List?) { assertTrue(result !is IncrementalExecutionResult) + assertTrue(incrementalResults == null) } } diff --git a/test/src/test/kotlin/graphql/nadel/tests/util/StriktUtil.kt b/test/src/test/kotlin/graphql/nadel/tests/util/StriktUtil.kt index 563e4c3f8..b694aa7f9 100644 --- a/test/src/test/kotlin/graphql/nadel/tests/util/StriktUtil.kt +++ b/test/src/test/kotlin/graphql/nadel/tests/util/StriktUtil.kt @@ -7,6 +7,7 @@ import graphql.nadel.engine.util.JsonMap import graphql.nadel.tests.assertJsonKeys import strikt.api.Assertion +@Deprecated("Do not use") fun , K, V> Assertion.Builder.keysEqual(expectedKeys: Collection): Assertion.Builder { compose("keys match expected") { actual -> val actualKeys = actual.keys @@ -33,21 +34,25 @@ fun , K, V> Assertion.Builder.keysEqual(expectedKeys: Collectio return this } +@Deprecated("Do not use") val Assertion.Builder.extensions: Assertion.Builder get() { return get { extensions as AnyMap }.assertJsonKeys() } +@Deprecated("Do not use") val Assertion.Builder.errors: Assertion.Builder> get() { return get { errors } } +@Deprecated("Do not use") val Assertion.Builder.message: Assertion.Builder get() { return get { message } } +@Deprecated("Do not use") val Assertion.Builder.data: Assertion.Builder get() { return get { @@ -55,12 +60,7 @@ val Assertion.Builder.data: Assertion.Builder } } -fun Assertion.Builder.getHashCode(): Assertion.Builder { - return get { - hashCode() - } -} - +@Deprecated("Do not use") fun Assertion.Builder.getToString(): Assertion.Builder { return get { toString()