Repository navigation
Unwrap nested return values the way the schema build matched them - #918
Merged
Merged
Conversation
ReturnValueUnwrapper worked out where to unwrap a return value with its own copy of the order TypeClassMatcher unwraps in. The matcher now records what it unwraps on the way to the matched type (generic wrappers, DataFetcherResult, Optional, list elements), the method's return value is matched from its declared type so the record starts at the outermost wrapper, and the data fetcher builds its steps from that record. That also unwraps a wrapper that only a custom schemaWrapper introduces, such as one whose schemaWrapper is List<CompletableFuture<T>>. The copy only looked for wrappers in the declared type's arguments.
Custom scalars were left out of the nested unwrapping, so a Coercing kept getting what it got before: [DateTime] from List<CompletableFuture<OffsetDateTime>> passed the schema build and then handed the futures to the scalar. A custom scalar now gets the values the schema build matched it to, like every other type.
A future directly inside another wrapper, as in Try<CompletableFuture<X>>, got no step, because the wrapper's step waited on the future before the next step ran. Every wrapper in the record now gets a step, and a wrapper hands a future to that future's step as is, so each step sees the value at its own position. A future's step waits on it. graphql-java still gets the exact future the resolver returned for Try<CompletableFuture<X>>, since that future's step has nothing below it.
A Future that isn't a CompletionStage inside a list or another wrapper, as in List<Future<Item>> holding a FutureTask, reached graphql-java as is and failed with "Expected source object to be an instance of 'Item'", unless the app's ValueUnboxer called get() on it. It now fails with the error the outermost value already gives, which says to return a CompletionStage.
A nested wrapper was unwrapped with the most specific wrapper its value is an instance of, the same search as for the outermost value. Each step now calls the transformer of the wrapper the schema build matched at that position, so a subclass registered as a wrapper of its own no longer takes over below the outermost value, and nested values skip the search. The outermost value is still picked by its class, since a resolver can be declared as Object and return a wrapper.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Fixes #905
Checklist
Description
#903 unwraps generic wrappers and futures nested in lists and other wrappers, but
ReturnValueUnwrapperworks out where to unwrap with its own copy of the orderTypeClassMatcherunwraps in. OnlyNestedGenericWrapperTestkept the two in step. It also kept a few cases where the schema build and the runtime disagree, so 14.x apps wouldn't break.Now the matcher records what it unwraps and the data fetcher replays that:
PotentialMatch.unwrappingis the matcher's walk, kept as a list ofWrapper(GenericWrapper),Data(DataFetcherResult),Present(Optional) andElements(list elements), outermost first.match()only looks up the named type at its endMethodFieldResolvermatches its return value from the declared type, so the record starts at the outermost wrapper. It builds the data fetcher's steps from the samePotentialMatchthe scanner matched, so nothing new goes through the scanner or the parserTry<CompletableFuture<X>>the future's step gets the future. Otherwise it waits on aCompletionStageits transformer returns (e.g. from aDataLoader)What changes, one per item of the issue:
A wrapper that only a custom
schemaWrapperintroduces is unwrapped too. E.g. aBatch<T>whoseschemaWrapperisList<CompletableFuture<T>>used to hand graphql-java the futures, since the old check only looked for wrappers in the declared type's arguments.Custom scalars aren't exempt anymore.
[DateTime]fromList<CompletableFuture<OffsetDateTime>>gives theCoercingtheOffsetDateTimes, and[JSON]fromList<Try<Map>>the maps.A nested
Futurethat isn't aCompletionStage(List<Future<Item>>holding aFutureTask, or a wrapper in a list whose transformer returns one) fails with the error the outermost value already gives:instead of
Expected source object to be an instance of 'Item' but instead got 'java.util.concurrent.FutureTask'. A suspend function gets the message its outermost value gets (Suspend function '...' resolved to a java.util.concurrent.FutureTask, which graphql-java can't wait on. Return its value instead.). A value of the class the return type ends in isn't rejected even if it implementsFuture. When that class isObject(e.g. for a union) every value is an instance of it, so only a declared future likeList<Future<Any>>is checked. A wrapper's transformer returning aFutureTaskthere still fails like on master, when graphql-java resolves the union's type (Expected object type with name 'FutureTask' to exist for union 'Item', but it doesn't!).A nested wrapper is unwrapped with the wrapper the schema build matched for its declared type, not the most specific one its value is an instance of. The record already has that wrapper, so this came for free, and nested values skip the wrapper search. It's only different when a subclass is registered as a wrapper of its own, e.g. a
ListenableFuturewrapper with aList<Future<Item>>return type. The outermost value is still picked by its class, since a resolver declared asObject(e.g. for a union) can return a wrapper.isFutureReturnedByWrapperBeforeis gone. It skipped the step of a future directly inside another wrapper, because that wrapper's step had already waited on it. Now the future gets its own step. With the default future wrappers nothing changes,Try<CompletableFuture<X>>still hands graphql-java the resolver's own future. But if you registered a wrapper for the future's type, e.g.CompletableFuturewith a transformer, that transformer now runs there and graphql-java gets the future it returns. Before, it was skipped and graphql-java got the resolver's own future.Fields with no nested wrapper do the same work per fetch as before, plus two checks. Steps only exist when the record has a wrapper below the outermost value (
Data,PresentandElementswith nothing below them are dropped). So the data fetcher still does the wrapper search,requireOutermostand returns the value. For a plain list the wrapper search returns before it allocates. ACompletableFuture<List<Item>>matches the default future wrapper, so like on master it also sorts the matching wrappers and runs the transformer. The two checks areisOutermostFutureand, when the outermost value isn't a future, whether the next step is a future's, which is false with no steps.Tradeoffs:
CompletionStagebetween steps) and only dropping the rule. It's less code, but then inTry<CompletableFuture<X>>the future's step gets theXinstead of the future. That's harmless while every step picks its wrapper by the value's class, but a check or a declared wrapper at that position acts on the wrong value. E.g. a transformer you registered forCompletableFuturegets theX, and graphql-java gets a new future instead of the resolver's own one.returns the resolver's own value when nothing is nestedcatches that.MatchthroughScannedSchemaObjectsandSchemaParserintocreateDataFetcher(...), or be filled in by a callback during the scan. Both add plumbing or mutable state for something the field resolver already holds, so it lives on thePotentialMatch(lazily, so the walk runs once).java.util.concurrent.CompletableFuture<Foo>) instead of the one with its wrappers removed (Foo). No test checked that text.resolves every return type the matcher acceptsstill builds every return type up to 4 deep, now withFutureas a container and a custom scalar leaf next toPayload. The scalar's value class implementsFuture, so it also checks that a value which only happens to be one isn't rejected. That's 56,020 combinations, 10,136 of them accepted by the matcher, and the 5,688 that contain aFutureexpect the "can't wait on" error. The same test written against master's API fails 5,382 of them on master (3,702 of those with the scalar leaf). Making the outermost step wait on a future before handing it over (the first tradeoff) failsreturns the resolver's own value when nothing is nested, and doing the same in the other wrappers' steps also fails one of the tests for a wrapper handing a future to its own wrapper. The matrix still passes either way. There are new tests for theschemaWrappercase, the subclass wrapper, a wrapper whose transformer returns aFutureTask, a suspend function's plain futures, plain futures for a union, a union member that implementsFuturein a list ofTrys and aTryhanding a plain future to its own registered wrapper, and the custom scalar and plain future tests now expect the new behaviour.mvn clean verifypasses on JDK 25 against graphql-java 26.1 and 25.1 (417 tests each).Also checked through the installed jar with verify-tools, on graphql-java 26.1 and 25.1:
nested-wrappers,nested-wrappers-unboxerandnested-wrappers-suspendgive identical results on master and this branch, sofeatures/generic-wrappers.mdstill holdsnested-wrappers-exactscenario. On master[DateTime]hands the scalar the futures and theTrys (Can't serialize value (/dates[0]) : Expected an OffsetDateTime but got a java.util.concurrent.CompletableFuture), theFutureTaskfails on its children, theschemaWrapper's futures fail the same way and the subclass wrapper's transformer runs. On this branch the dates serialize, theFutureTaskgives the error above and the other two resolve. It also has a wrapper whose transformer returns aFutureTaskand a suspend function returningList<Future<Item>>. Both fail on their children on master and give the errors from 3 on this branchI didn't run JMH. That nested values are faster without the wrapper search is a guess.
Release note:
schemaWrapperintroduces is now unwrapped below the outermost value like any other[DateTime]fromList<CompletableFuture<OffsetDateTime>>gets theOffsetDateTimes. ACoercingthat unwrapped them itself now only sees the plain valuesFuturethat isn't aCompletionStage, nested in a list or another wrapper or returned by a nested wrapper's transformer (unless the return type ends inObject), now fails the field with "graphql-java can't wait on" (the suspend function message for a suspend function). If yourValueUnboxercalledget()on it, return aCompletableFutureinsteadTry<CompletableFuture<X>>) now goes through the transformer of a wrapper you registered for its type, which used to be skipped there. With the default future wrappers it still reaches graphql-java as the future the resolver returned. If you need the resolver's own future there, declare it as a type you didn't register a wrapper for (e.g.Try<CompletionStage<X>>) or make the transformer return the future it gets