Skip to content

Honour null generic wrapper results and await plain futures - #836

Open
oryan-block wants to merge 1 commit into
masterfrom
bugfix/371
Open

oryan-block wants to merge 1 commit into
masterfrom
bugfix/371

Conversation

@oryan-block

Copy link
Copy Markdown
Collaborator

Fixes #371
Fixes #203

Checklist

  • Pull requests follows the contribution guide
  • New or modified functionality is covered by tests

Description

A generic wrapper transformer can't map a value to null. #371 registers wrappers for Arrow's Option, None and Some, with None transformed to null, and a nullable errors: [Error!] field returning None fails with Can't resolve value (/user/register/errors) : type mismatch error, expected type LIST got class arrow.core.None. Every other case works, only the empty one doesn't. transformWithGenericWrapper ended with ?.transformer?.invoke(this, env) ?: this, so when the transformer returned null the elvis fell back to the original wrapper object. A scalar field then serialized the wrapper's toString, and an object or list field failed with a type or source mismatch. The transformer result is now returned as is, null included, and the original object is only kept when no wrapper matches. MethodFieldResolverDataFetcher (suspend path included) and LightMethodFieldResolverDataFetcher both go through it.

In #203 a resolver declared as Future<Organization> returns RxJava's Single#toFuture(), and its child fields fail with Expected source object to be an instance of '...Organization' but instead got 'io.reactivex.internal.observers.FutureSingleObserver'. The test in the thread only passed because it returned null or a CompletableFuture. The default GenericWrapper(Future::class, 0) is an identity transformer, so it only unwraps the type at scan time. At runtime graphql-java only awaits a CompletionStage, so any other Future became the field value. Now when a method's declared raw return type is exactly Future (the Kotlin return type for suspend functions, same lookup the scan uses, moved into getReturnType()), a returned future that isn't a CompletionStage is waited on with a blocking get() on the fetching thread after the generic wrapper transformer runs. That's the same as the resolver calling get() itself. The cause of an ExecutionException is rethrown so the error shows the resolver's own exception, same as invoke does with InvocationTargetException, and the interrupt flag is restored before an InterruptedException is rethrown. CompletionStages are left alone. A subscription declared Future<Publisher<Event>> that returns a plain future works now too (it threw on master). The new tests are in MethodFieldResolverTest and ReactiveTest, and all four fail on master.

The check is on the declared type rather than isInstance on purpose. I first put the wait into the default Future wrapper's transformer, but wrappers match with isInstance at runtime, so a domain object that happens to implement Future (fun job(): Job with class Job : Future<String>) got get() called on it. Its result replaced the source, or the request hung if it wasn't done yet. So the default wrapper is untouched. I went with blocking over something like supplyAsync because a plain Future has no completion callback, so some thread has to block either way. Blocking commonPool threads could starve everything else on that pool (batch loaders included), and a dedicated executor would need new API. If we want that it's a separate change.

tfadsilva's comment on #203 still fails: a custom transformer that itself returns a plain future (Flowable.toList().toFuture()) gets type mismatch error, expected type LIST got class ...FutureSingleObserver. Transformer output isn't passed through the wrappers again and its declared type isn't known, so waiting on it would bring back the problem above. Returning a CompletionStage from the transformer, or blocking inside it, works. There's no timeout on get(). A return type that's a type variable bound to Future (fun item(): R in Base<R> with Q : Base<Future<Item>>), or a subscription declared as a Future subtype like FutureTask<Publisher<Event>>, still passes the scan without being waited on, same as master.

I also looked at #254 (List<Try<Payload>> with a Vavr Try wrapper) alongside these, and it's not fixed here. It still fails with Expected source object to be an instance of ...Payload but instead got ...Try$Success. The scanner accepts any registered wrapper under a list, defaults included, so List<CompletableFuture<T>> passes the scan and fails the same way at runtime. Fixing it needs either a runtime transform driven by the declared type that walks lists, arrays and nested lists and goes through CompletionStage and DataFetcherResult, or rejecting nested wrappers at scan time like nested Optional already is, which would break schemas that build today. That's a call for a maintainer, so I left it open.

Behaviour change: a generic wrapper transformer that returns null now makes the field null. Before, the original wrapper was passed through, so anyone relying on null to mean "leave it alone" now gets null. Nothing changes when no wrapper matches. A resolver declared to return Future that returns a plain future now blocks the fetching thread until it's done (a coroutine dispatcher thread for suspend functions). Before, the field always failed. So a plain future that only completes after graphql-java moves on, like one backed by a DataLoader load, now hangs instead of failing right away with a type mismatch. Those should return a CompletionStage. CompletableFuture and other CompletionStages behave the same as before.

🤖 Generated with Claude Code

A generic wrapper transformer that returned null was ignored, because
transformWithGenericWrapper fell back to the original object with an
elvis operator. Option-like wrappers could therefore never map their
empty case to null: a scalar field serialized the wrapper's toString
and an object or list field failed at runtime. The transformer result
is now returned as is, including null, and the original object is only
kept when no wrapper matches.

The default Future wrapper only unwraps the type at scan time. At
runtime graphql-java only awaits a CompletionStage, so any other Future
(a FutureTask, or the observer returned by RxJava's toFuture()) reached
graphql-java as the field value and child fields failed with a source
type mismatch. When a resolver method is declared to return Future, a
returned future that is not a CompletionStage is now waited on with a
blocking get() on the fetching thread, as if the resolver had called
get() itself. The cause of an ExecutionException is rethrown so the
resolver's own error is reported, and the interrupt flag is restored
before an InterruptedException is rethrown. The check uses the method's
declared raw return type, so a returned object that merely implements
Future is left alone.

Because the wait blocks, a plain future that only completes after
graphql-java moves on, such as one backed by a DataLoader load, now
hangs instead of failing with a type mismatch. Such resolvers should
return a CompletionStage. A plain future returned by a custom generic
wrapper transformer is still not waited on unless the method itself is
declared to return Future.

Fixes #371
Fixes #203

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Oct 3, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generic wrapper for Arrow library Option Help with Default Generic Wrappers

1 participant