From 5dd358385c3f416d89fadfd17d020775a20892a6 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Sun, 21 Apr 2024 19:19:06 +1000 Subject: [PATCH 01/67] Reproduction showing async DFs keeping field order https://github.com/spring-projects/spring-graphql/issues/949 --- build.gradle | 1 + .../SubscriptionReproduction.java | 177 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 src/test/java/reproductions/SubscriptionReproduction.java diff --git a/build.gradle b/build.gradle index 3750da10cb..ab8254700c 100644 --- a/build.gradle +++ b/build.gradle @@ -117,6 +117,7 @@ dependencies { testImplementation 'org.reactivestreams:reactive-streams-tck:' + reactiveStreamsVersion testImplementation "io.reactivex.rxjava2:rxjava:2.2.21" + testImplementation "io.projectreactor:reactor-core:3.6.5" testImplementation 'org.testng:testng:7.10.1' // use for reactive streams test inheritance diff --git a/src/test/java/reproductions/SubscriptionReproduction.java b/src/test/java/reproductions/SubscriptionReproduction.java new file mode 100644 index 0000000000..84b76dcdfb --- /dev/null +++ b/src/test/java/reproductions/SubscriptionReproduction.java @@ -0,0 +1,177 @@ +package reproductions; + +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.RuntimeWiring; +import graphql.schema.idl.SchemaGenerator; +import graphql.schema.idl.SchemaParser; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import reactor.core.publisher.Flux; + +import javax.annotation.Nonnull; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import static graphql.schema.idl.RuntimeWiring.newRuntimeWiring; +import static graphql.schema.idl.TypeRuntimeWiring.newTypeWiring; + +/** + * Related to ... + *

+ * This reproduction is to see what's happening with Subscriptions and whether they keep their + * order when values are async. + */ +public class SubscriptionReproduction { + public static void main(String[] args) { + new SubscriptionReproduction().run(); + } + + private void run() { + + GraphQL graphQL = mkGraphQl(); + String query = "subscription MySubscription {\n" + + " searchVideo {\n" + + " id\n" + + " name\n" + + " isFavorite\n" + + " }\n" + + "}"; + ExecutionResult executionResult = graphQL.execute(query); + Publisher> publisher = executionResult.getData(); + + DeathEater eater = new DeathEater(); + eater.eat(publisher); + } + + private GraphQL mkGraphQl() { + String sdl = "type Query { f : ID }" + + "type Subscription {" + + " searchVideo : VideoSearch" + + "}" + + "type VideoSearch {" + + " id : ID" + + " name : String" + + " isFavorite : Boolean" + + "}"; + RuntimeWiring runtimeWiring = newRuntimeWiring() + .type(newTypeWiring("Subscription") + .dataFetcher("searchVideo", this::mkFluxDF) + ) + .type(newTypeWiring("VideoSearch") + .dataFetcher("name", this::nameDF) + .dataFetcher("isFavorite", this::isFavoriteDF) + ) + .build(); + + GraphQLSchema schema = new SchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), runtimeWiring + ); + return GraphQL.newGraphQL(schema).build(); + } + + private CompletableFuture> mkFluxDF(DataFetchingEnvironment env) { + // async deliver of the publisher with random snoozing between values + return CompletableFuture.supplyAsync(() -> Flux.generate(() -> 0, (counter, sink) -> { + sink.next(mkValue(counter)); + snooze(rand(10, 100)); + if (counter == 10) { + sink.complete(); + } + return counter + 1; + })); + } + + private Object isFavoriteDF(DataFetchingEnvironment env) { + // async deliver of the isFavorite property with random delay + return CompletableFuture.supplyAsync(() -> { + Integer counter = getCounter(env.getSource()); + return counter % 2 == 0; + }); + } + + private Object nameDF(DataFetchingEnvironment env) { + // async deliver of the isFavorite property with random delay + return CompletableFuture.supplyAsync(() -> { + Integer counter = getCounter(env.getSource()); + return "name" + counter; + }); + } + + private static Integer getCounter(Map video) { + Integer counter = (Integer) video.getOrDefault("counter", 0); + snooze(rand(100, 500)); + return counter; + } + + private @Nonnull Object mkValue(Integer counter) { + // name and isFavorite are future values via DFs + return Map.of( + "counter", counter, + "id", String.valueOf(counter) // immediate value + ); + } + + + private static void snooze(int ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + static Random rn = new Random(); + + private static int rand(int min, int max) { + return rn.nextInt(max - min + 1) + min; + } + + public static class DeathEater implements Subscriber { + private Subscription subscription; + private final AtomicBoolean done = new AtomicBoolean(); + + public boolean isDone() { + return done.get(); + } + + @Override + public void onSubscribe(Subscription subscription) { + this.subscription = subscription; + System.out.println("onSubscribe"); + subscription.request(1); + } + + @Override + public void onNext(Object o) { + System.out.println("\tonNext : " + o); + subscription.request(1); + } + + @Override + public void onError(Throwable throwable) { + System.out.println("onError"); + throwable.printStackTrace(System.err); + done.set(true); + } + + @Override + public void onComplete() { + System.out.println("complete"); + done.set(true); + } + + public void eat(Publisher publisher) { + publisher.subscribe(this); + while (!this.isDone()) { + snooze(2); + } + + } + } +} From ed39788f730c3c0b817db4d5fc81462faea6addf Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Sun, 21 Apr 2024 19:58:45 +1000 Subject: [PATCH 02/67] Updated to have a Mono property --- .../reproductions/SubscriptionReproduction.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/java/reproductions/SubscriptionReproduction.java b/src/test/java/reproductions/SubscriptionReproduction.java index 84b76dcdfb..35cbd53fb2 100644 --- a/src/test/java/reproductions/SubscriptionReproduction.java +++ b/src/test/java/reproductions/SubscriptionReproduction.java @@ -11,6 +11,8 @@ import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import javax.annotation.Nonnull; import java.util.Map; @@ -39,6 +41,7 @@ private void run() { " searchVideo {\n" + " id\n" + " name\n" + + " lastEpisode\n" + " isFavorite\n" + " }\n" + "}"; @@ -57,6 +60,7 @@ private GraphQL mkGraphQl() { "type VideoSearch {" + " id : ID" + " name : String" + + " lastEpisode : String" + " isFavorite : Boolean" + "}"; RuntimeWiring runtimeWiring = newRuntimeWiring() @@ -66,6 +70,7 @@ private GraphQL mkGraphQl() { .type(newTypeWiring("VideoSearch") .dataFetcher("name", this::nameDF) .dataFetcher("isFavorite", this::isFavoriteDF) + .dataFetcher("lastEpisode", this::lastEpisode) ) .build(); @@ -95,6 +100,16 @@ private Object isFavoriteDF(DataFetchingEnvironment env) { }); } + private Object lastEpisode(DataFetchingEnvironment env) { + // Mono-based async property that uses CF as the interface + return Mono.fromCallable(() -> { + Integer counter = getCounter(env.getSource()); + return "episode-" + Thread.currentThread().getName() + "for" + counter; + }) + .publishOn(Schedulers.boundedElastic()) + .toFuture(); + } + private Object nameDF(DataFetchingEnvironment env) { // async deliver of the isFavorite property with random delay return CompletableFuture.supplyAsync(() -> { From 5520a2dbba103ba2cd3a099dc47408e10548a956 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Mon, 22 Apr 2024 23:03:16 +1000 Subject: [PATCH 03/67] This has a fix to buffer published events to keep them in order --- .../CompletionStageMappingPublisher.java | 77 ++++++++-- src/test/groovy/graphql/TestUtil.groovy | 7 + .../execution/pubsub/CapturingSubscriber.java | 32 +++- ...sherRandomCompleteTckVerificationTest.java | 65 +++++++++ ...geMappingPublisherTckVerificationTest.java | 45 ++++++ ...CompletionStageMappingPublisherTest.groovy | 138 +++++++++++++++++- .../SubscriptionReproduction.java | 8 +- 7 files changed, 341 insertions(+), 31 deletions(-) create mode 100644 src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java create mode 100644 src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java diff --git a/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java b/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java index 18bfabbc6c..f003e154a8 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java @@ -8,12 +8,16 @@ import java.util.ArrayDeque; import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.Function; +import static graphql.Assert.assertNotNullWithNPE; + /** * A reactive Publisher that bridges over another Publisher of `D` and maps the results * to type `U` via a CompletionStage, handling errors in that stage @@ -40,6 +44,7 @@ public CompletionStageMappingPublisher(Publisher upstreamPublisher, Function< @Override public void subscribe(Subscriber downstreamSubscriber) { + assertNotNullWithNPE(downstreamSubscriber, () -> "Subscriber passed to subscribe must not be null"); upstreamPublisher.subscribe(new CompletionStageSubscriber(downstreamSubscriber)); } @@ -85,23 +90,28 @@ public void onNext(U u) { try { CompletionStage completionStage = mapper.apply(u); offerToInFlightQ(completionStage); - completionStage.whenComplete(whenNextFinished(completionStage)); + completionStage.whenComplete(whenNextFinished()); } catch (RuntimeException throwable) { handleThrowable(throwable); } } - private BiConsumer whenNextFinished(CompletionStage completionStage) { + private BiConsumer whenNextFinished() { return (d, throwable) -> { try { if (throwable != null) { handleThrowable(throwable); } else { - downstreamSubscriber.onNext(d); + emptyInFlightQueueIfWeCan(); } } finally { Runnable runOnCompleteOrErrorRun = onCompleteOrErrorRun.get(); - boolean empty = removeFromInFlightQAndCheckIfEmpty(completionStage); + boolean empty = inFlightQIsEmpty(); + // + // if the runOnCompleteOrErrorRun runnable is set, the upstream has + // called onError() or onComplete() already, but the CFs have not all completed + // yet, so we have to check whenever a CF completes + // if (empty && runOnCompleteOrErrorRun != null) { onCompleteOrErrorRun.set(null); runOnCompleteOrErrorRun.run(); @@ -110,13 +120,51 @@ private BiConsumer whenNextFinished(CompletionStage completionS }; } + private void emptyInFlightQueueIfWeCan() { + // done inside a memory lock, so we cant offer new CFs to the queue + // until we have processed any completed ones from the start of + // the queue. + lock.runLocked(() -> { + // + // from the top of the in flight queue, take all the CFs that have + // completed... but stop if they are not done + while (!inFlightDataQ.isEmpty()) { + CompletionStage cs = inFlightDataQ.peek(); + if (cs != null) { + // + CompletableFuture cf = cs.toCompletableFuture(); + if (cf.isDone()) { + // take it off the queue + inFlightDataQ.poll(); + D value; + try { + //noinspection unchecked + value = (D) cf.join(); + } catch (RuntimeException rte) { + // + // if we get an exception while joining on a value, we + // send it into the exception handling and break out + handleThrowable(cfExceptionUnwrap(rte)); + break; + } + downstreamSubscriber.onNext(value); + } else { + // if the CF is not done, then we have to stop processing + // to keep the results in order inside the inFlightQueue + break; + } + } + } + }); + } + private void handleThrowable(Throwable throwable) { downstreamSubscriber.onError(throwable); // - // reactive semantics say that IF an exception happens on a publisher + // Reactive semantics say that IF an exception happens on a publisher, // then onError is called and no more messages flow. But since the exception happened - // during the mapping, the upstream publisher does not no about this. - // so we cancel to bring the semantics back together, that is as soon as an exception + // during the mapping, the upstream publisher does not know about this. + // So we cancel to bring the semantics back together, that is as soon as an exception // has happened, no more messages flow // delegatingSubscription.cancel(); @@ -162,16 +210,15 @@ private void offerToInFlightQ(CompletionStage completionStage) { ); } - private boolean removeFromInFlightQAndCheckIfEmpty(CompletionStage completionStage) { - // uncontested locks in java are cheap - we don't expect much contention here - return lock.callLocked(() -> { - inFlightDataQ.remove(completionStage); - return inFlightDataQ.isEmpty(); - }); - } - private boolean inFlightQIsEmpty() { return lock.callLocked(inFlightDataQ::isEmpty); } + + private Throwable cfExceptionUnwrap(Throwable throwable) { + if (throwable instanceof CompletionException & throwable.getCause() != null) { + return throwable.getCause(); + } + return throwable; + } } } diff --git a/src/test/groovy/graphql/TestUtil.groovy b/src/test/groovy/graphql/TestUtil.groovy index 490e7cee93..dea2c2c7ce 100644 --- a/src/test/groovy/graphql/TestUtil.groovy +++ b/src/test/groovy/graphql/TestUtil.groovy @@ -316,4 +316,11 @@ class TestUtil { return JsonOutput.prettyPrint(JsonOutput.toJson(obj)) } + + static Random rn = new Random() + + static int rand(int min, int max) { + return rn.nextInt(max - min + 1) + min + } + } diff --git a/src/test/groovy/graphql/execution/pubsub/CapturingSubscriber.java b/src/test/groovy/graphql/execution/pubsub/CapturingSubscriber.java index f736807941..376bb3e774 100644 --- a/src/test/groovy/graphql/execution/pubsub/CapturingSubscriber.java +++ b/src/test/groovy/graphql/execution/pubsub/CapturingSubscriber.java @@ -3,9 +3,11 @@ import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; /** * A subscriber that captures each object for testing @@ -13,37 +15,53 @@ public class CapturingSubscriber implements Subscriber { private final List events = new ArrayList<>(); private final AtomicBoolean done = new AtomicBoolean(); + private final AtomicLong creationTime = new AtomicLong(System.nanoTime()); + private final int requestN; private Subscription subscription; private Throwable throwable; + public CapturingSubscriber() { + this(1); + } + + public CapturingSubscriber(int requestN) { + this.requestN = requestN; + } @Override public void onSubscribe(Subscription subscription) { - System.out.println("onSubscribe called at " + System.nanoTime()); + System.out.println("onSubscribe called at " + delta()); this.subscription = subscription; - subscription.request(1); + subscription.request(requestN); } @Override public void onNext(T t) { - System.out.println("onNext called at " + System.nanoTime()); - events.add(t); - subscription.request(1); + System.out.println("\tonNext " + t + " called at " + delta()); + synchronized (this) { + events.add(t); + subscription.request(requestN); + } } @Override public void onError(Throwable t) { - System.out.println("onError called at " + System.nanoTime()); + System.out.println("onError called at " + delta()); this.throwable = t; done.set(true); } @Override public void onComplete() { - System.out.println("onComplete called at " + System.nanoTime()); + System.out.println("onComplete called at " + delta()); done.set(true); } + private String delta() { + Duration nanos = Duration.ofNanos(System.nanoTime() - creationTime.get()); + return "+" + nanos.toMillis() + "ms"; + } + public List getEvents() { return events; } diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java new file mode 100644 index 0000000000..7e3d850323 --- /dev/null +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java @@ -0,0 +1,65 @@ +package graphql.execution.reactive; + +import io.reactivex.Flowable; +import org.jetbrains.annotations.NotNull; +import org.reactivestreams.Publisher; +import org.reactivestreams.tck.PublisherVerification; +import org.reactivestreams.tck.TestEnvironment; +import org.testng.annotations.Test; + +import java.time.Duration; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +/** + * This uses the reactive streams TCK to test that our CompletionStageMappingPublisher meets spec + * when it's got CFs that complete at different times + */ +@Test +public class CompletionStageMappingPublisherRandomCompleteTckVerificationTest extends PublisherVerification { + + public CompletionStageMappingPublisherRandomCompleteTckVerificationTest() { + super(new TestEnvironment(Duration.ofMillis(100).toMillis())); + } + + @Override + public long maxElementsFromPublisher() { + return 10000; + } + + @Override + public Publisher createPublisher(long elements) { + Publisher publisher = Flowable.range(0, (int) elements); + Function> mapper = mapperFunc(); + return new CompletionStageMappingPublisher<>(publisher, mapper); + } + @Override + public Publisher createFailedPublisher() { + Publisher publisher = Flowable.error(() -> new RuntimeException("Bang")); + Function> mapper = mapperFunc(); + return new CompletionStageMappingPublisher<>(publisher, mapper); + } + + @NotNull + private static Function> mapperFunc() { + return i -> CompletableFuture.supplyAsync(() -> { + int ms = rand(0, 5); + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return i + "!"; + }); + } + + static Random rn = new Random(); + + private static int rand(int min, int max) { + return rn.nextInt(max - min + 1) + min; + } + +} + diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java new file mode 100644 index 0000000000..b4bb1a9b60 --- /dev/null +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java @@ -0,0 +1,45 @@ +package graphql.execution.reactive; + +import io.reactivex.Flowable; +import org.reactivestreams.Publisher; +import org.reactivestreams.tck.PublisherVerification; +import org.reactivestreams.tck.TestEnvironment; +import org.testng.annotations.Test; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +/** + * This uses the reactive streams TCK to test that our CompletionStageMappingPublisher meets spec + * when it's got CFs that complete off thread + */ +@Test +public class CompletionStageMappingPublisherTckVerificationTest extends PublisherVerification { + + public CompletionStageMappingPublisherTckVerificationTest() { + super(new TestEnvironment(Duration.ofMillis(100).toMillis())); + } + + @Override + public long maxElementsFromPublisher() { + return 10000; + } + + @Override + public Publisher createPublisher(long elements) { + Publisher publisher = Flowable.range(0, (int) elements); + Function> mapper = i -> CompletableFuture.supplyAsync(() -> i + "!"); + return new CompletionStageMappingPublisher<>(publisher, mapper); + } + + @Override + public Publisher createFailedPublisher() { + Publisher publisher = Flowable.error(() -> new RuntimeException("Bang")); + Function> mapper = i -> CompletableFuture.supplyAsync(() -> i + "!"); + return new CompletionStageMappingPublisher<>(publisher, mapper); + } + +} + diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy index c2117f9a8d..f0719d21eb 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy @@ -1,5 +1,6 @@ package graphql.execution.reactive +import graphql.TestUtil import graphql.execution.pubsub.CapturingSubscriber import io.reactivex.Flowable import org.awaitility.Awaitility @@ -7,7 +8,10 @@ import org.reactivestreams.Publisher import spock.lang.Specification import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException import java.util.concurrent.CompletionStage +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import java.util.function.Function class CompletionStageMappingPublisherTest extends Specification { @@ -31,11 +35,11 @@ class CompletionStageMappingPublisherTest extends Specification { then: capturingSubscriber.events.size() == 10 - capturingSubscriber.events[0] instanceof String - capturingSubscriber.events[0] == "0" + // order is kept + capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] } - def "multiple subscribers get there messages"() { + def "multiple subscribers get their messages"() { when: Publisher rxIntegers = Flowable.range(0, 10) @@ -56,7 +60,12 @@ class CompletionStageMappingPublisherTest extends Specification { then: capturingSubscriber1.events.size() == 10 + // order is kept + capturingSubscriber1.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + capturingSubscriber2.events.size() == 10 + // order is kept + capturingSubscriber2.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] } def "error handling"() { @@ -87,7 +96,78 @@ class CompletionStageMappingPublisherTest extends Specification { // // got this far and cancelled capturingSubscriber.events.size() == 5 + capturingSubscriber.events == ["0", "1", "2", "3", "4",] + + } + + def "error handling when the error happens in the middle of the processing but before the others complete"() { + when: + Publisher rxIntegers = Flowable.range(0, 10) + + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + + if (integer == 5) { + return CompletableFuture.supplyAsync { + throw new RuntimeException("Bang") + } + } else { + return asyncValueAfterDelay(100, integer) + } + } + } + Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) + + def capturingSubscriber = new CapturingSubscriber<>(10) + rxStrings.subscribe(capturingSubscriber) + then: + Awaitility.await().untilTrue(capturingSubscriber.isDone()) + + unwrap(capturingSubscriber.throwable).getMessage() == "Bang" + } + + def "error handling when the error happens in the middle of the processing but after the previous ones complete"() { + when: + Publisher rxIntegers = Flowable.range(0, 10) + + CountDownLatch latch = new CountDownLatch(5) + CountDownLatch exceptionLatch = new CountDownLatch(1) + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + + if (integer == 5) { + return CompletableFuture.supplyAsync { + exceptionLatch.await(10_000, TimeUnit.SECONDS) + sleep(100) + throw new RuntimeException("Bang") + } + } else if (integer == 6) { + return CompletableFuture.supplyAsync { + latch.countDown() + exceptionLatch.countDown() // number 5 is now alive + return String.valueOf(integer) + } + } else { + return CompletableFuture.supplyAsync { + latch.countDown() + latch.await(10_000, TimeUnit.SECONDS) + return String.valueOf(integer) + } + } + } + } + Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) + + def capturingSubscriber = new CapturingSubscriber<>(10) + rxStrings.subscribe(capturingSubscriber) + + then: + Awaitility.await().untilTrue(capturingSubscriber.isDone()) + + unwrap(capturingSubscriber.throwable).getMessage() == "Bang" } @@ -117,7 +197,7 @@ class CompletionStageMappingPublisherTest extends Specification { // // got this far and cancelled capturingSubscriber.events.size() == 5 - + capturingSubscriber.events == ["0", "1", "2", "3", "4",] } @@ -137,8 +217,29 @@ class CompletionStageMappingPublisherTest extends Specification { Awaitility.await().untilTrue(capturingSubscriber.isDone()) capturingSubscriber.events.size() == 10 - capturingSubscriber.events[0] instanceof String - capturingSubscriber.events[0] == "0" + // order is kept + capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + } + + def "asynchronous mapping works when they complete out of order"() { + + when: + Publisher rxIntegers = Flowable.range(0, 10) + + Function> mapper = mapperThatRandomlyDelaysFor(20, 50) + Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) + + // ask for 10 at a time to create some forward pressure + def capturingSubscriber = new CapturingSubscriber<>(10) + rxStrings.subscribe(capturingSubscriber) + + then: + + Awaitility.await().untilTrue(capturingSubscriber.isDone()) + + capturingSubscriber.events.size() == 10 + // the original flow order was kept + capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] } Function> mapperThatDelaysFor(int delay) { @@ -147,6 +248,7 @@ class CompletionStageMappingPublisherTest extends Specification { CompletionStage apply(Integer integer) { return CompletableFuture.supplyAsync({ Thread.sleep(delay) + println "\t\tcompleted : " + integer return String.valueOf(integer) }) } @@ -154,4 +256,28 @@ class CompletionStageMappingPublisherTest extends Specification { mapper } + Function> mapperThatRandomlyDelaysFor(int delayMin, int delayMax) { + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + return asyncValueAfterDelay(TestUtil.rand(delayMin, delayMax), integer) + } + } + mapper + } + + private static CompletableFuture asyncValueAfterDelay(int delay, int integer) { + return CompletableFuture.supplyAsync({ + Thread.sleep(delay) + println "\t\tcompleted : " + integer + return String.valueOf(integer) + }) + } + + private static Throwable unwrap(Throwable throwable) { + if (throwable instanceof CompletionException) { + return ((CompletionException) throwable).getCause() + } + return throwable + } } diff --git a/src/test/java/reproductions/SubscriptionReproduction.java b/src/test/java/reproductions/SubscriptionReproduction.java index 35cbd53fb2..59877bffc9 100644 --- a/src/test/java/reproductions/SubscriptionReproduction.java +++ b/src/test/java/reproductions/SubscriptionReproduction.java @@ -19,6 +19,7 @@ import java.util.Random; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; import static graphql.schema.idl.RuntimeWiring.newRuntimeWiring; import static graphql.schema.idl.TypeRuntimeWiring.newTypeWiring; @@ -82,14 +83,15 @@ private GraphQL mkGraphQl() { private CompletableFuture> mkFluxDF(DataFetchingEnvironment env) { // async deliver of the publisher with random snoozing between values - return CompletableFuture.supplyAsync(() -> Flux.generate(() -> 0, (counter, sink) -> { + Supplier> fluxSupplier = () -> Flux.generate(() -> 0, (counter, sink) -> { sink.next(mkValue(counter)); snooze(rand(10, 100)); if (counter == 10) { sink.complete(); } return counter + 1; - })); + }); + return CompletableFuture.supplyAsync(fluxSupplier); } private Object isFavoriteDF(DataFetchingEnvironment env) { @@ -159,7 +161,7 @@ public boolean isDone() { public void onSubscribe(Subscription subscription) { this.subscription = subscription; System.out.println("onSubscribe"); - subscription.request(1); + subscription.request(10); } @Override From 23c3689069c4505ab6df5535be8675df4d43f732 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Tue, 23 Apr 2024 11:27:20 +1000 Subject: [PATCH 04/67] This has a fix to buffer published events to keep them in order - tweaks on tests and how we handle things --- .../CompletionStageMappingPublisher.java | 23 +++++++++++-------- ...sherRandomCompleteTckVerificationTest.java | 4 ++++ ...geMappingPublisherTckVerificationTest.java | 6 ++++- ...CompletionStageMappingPublisherTest.groovy | 4 ++-- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java b/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java index f003e154a8..568060800e 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java @@ -66,12 +66,14 @@ public class CompletionStageSubscriber implements Subscriber { final LockKit.ReentrantLock lock = new LockKit.ReentrantLock(); final AtomicReference onCompleteOrErrorRun; final AtomicBoolean onCompleteOrErrorRunCalled; + final AtomicBoolean upstreamCancelled; public CompletionStageSubscriber(Subscriber downstreamSubscriber) { this.downstreamSubscriber = downstreamSubscriber; inFlightDataQ = new ArrayDeque<>(); onCompleteOrErrorRun = new AtomicReference<>(); onCompleteOrErrorRunCalled = new AtomicBoolean(false); + upstreamCancelled = new AtomicBoolean(false); } @@ -159,15 +161,18 @@ private void emptyInFlightQueueIfWeCan() { } private void handleThrowable(Throwable throwable) { - downstreamSubscriber.onError(throwable); - // - // Reactive semantics say that IF an exception happens on a publisher, - // then onError is called and no more messages flow. But since the exception happened - // during the mapping, the upstream publisher does not know about this. - // So we cancel to bring the semantics back together, that is as soon as an exception - // has happened, no more messages flow - // - delegatingSubscription.cancel(); + // only do this once + if (upstreamCancelled.compareAndSet(false,true)) { + downstreamSubscriber.onError(throwable); + // + // Reactive semantics say that IF an exception happens on a publisher, + // then onError is called and no more messages flow. But since the exception happened + // during the mapping, the upstream publisher does not know about this. + // So we cancel to bring the semantics back together, that is as soon as an exception + // has happened, no more messages flow + // + delegatingSubscription.cancel(); + } } @Override diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java index 7e3d850323..dba025be26 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java @@ -42,6 +42,10 @@ public Publisher createFailedPublisher() { return new CompletionStageMappingPublisher<>(publisher, mapper); } + public boolean skipStochasticTests() { + return true; + } + @NotNull private static Function> mapperFunc() { return i -> CompletableFuture.supplyAsync(() -> { diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java index b4bb1a9b60..39a4fa8bf7 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java @@ -19,7 +19,7 @@ public class CompletionStageMappingPublisherTckVerificationTest extends PublisherVerification { public CompletionStageMappingPublisherTckVerificationTest() { - super(new TestEnvironment(Duration.ofMillis(100).toMillis())); + super(new TestEnvironment(Duration.ofMillis(1000).toMillis())); } @Override @@ -41,5 +41,9 @@ public Publisher createFailedPublisher() { return new CompletionStageMappingPublisher<>(publisher, mapper); } + @Override + public boolean skipStochasticTests() { + return true; + } } diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy index f0719d21eb..ec665a0210 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy @@ -113,7 +113,7 @@ class CompletionStageMappingPublisherTest extends Specification { throw new RuntimeException("Bang") } } else { - return asyncValueAfterDelay(100, integer) + return asyncValueAfterDelay(10, integer) } } } @@ -226,7 +226,7 @@ class CompletionStageMappingPublisherTest extends Specification { when: Publisher rxIntegers = Flowable.range(0, 10) - Function> mapper = mapperThatRandomlyDelaysFor(20, 50) + Function> mapper = mapperThatRandomlyDelaysFor(5, 15) Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) // ask for 10 at a time to create some forward pressure From 916232b5e73e045bdea2ce13d8e80e30d4ff28e7 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Tue, 23 Apr 2024 16:45:54 +1000 Subject: [PATCH 05/67] This has a fix to buffer published events to keep them in order - tweaks on tests and how we handle things --- .../CompletionStageMappingPublisher.java | 9 +++-- ...CompletionStageMappingPublisherTest.groovy | 35 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java b/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java index 568060800e..9970bf54d2 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java @@ -66,14 +66,14 @@ public class CompletionStageSubscriber implements Subscriber { final LockKit.ReentrantLock lock = new LockKit.ReentrantLock(); final AtomicReference onCompleteOrErrorRun; final AtomicBoolean onCompleteOrErrorRunCalled; - final AtomicBoolean upstreamCancelled; + final AtomicBoolean subscriptionCancelled; public CompletionStageSubscriber(Subscriber downstreamSubscriber) { this.downstreamSubscriber = downstreamSubscriber; inFlightDataQ = new ArrayDeque<>(); onCompleteOrErrorRun = new AtomicReference<>(); onCompleteOrErrorRunCalled = new AtomicBoolean(false); - upstreamCancelled = new AtomicBoolean(false); + subscriptionCancelled = new AtomicBoolean(false); } @@ -123,6 +123,9 @@ private BiConsumer whenNextFinished() { } private void emptyInFlightQueueIfWeCan() { + if (subscriptionCancelled.get()) { + return; + } // done inside a memory lock, so we cant offer new CFs to the queue // until we have processed any completed ones from the start of // the queue. @@ -162,7 +165,7 @@ private void emptyInFlightQueueIfWeCan() { private void handleThrowable(Throwable throwable) { // only do this once - if (upstreamCancelled.compareAndSet(false,true)) { + if (subscriptionCancelled.compareAndSet(false,true)) { downstreamSubscriber.onError(throwable); // // Reactive semantics say that IF an exception happens on a publisher, diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy index ec665a0210..891871b83f 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy @@ -10,8 +10,6 @@ import spock.lang.Specification import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionException import java.util.concurrent.CompletionStage -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import java.util.function.Function class CompletionStageMappingPublisherTest extends Specification { @@ -132,30 +130,29 @@ class CompletionStageMappingPublisherTest extends Specification { when: Publisher rxIntegers = Flowable.range(0, 10) - CountDownLatch latch = new CountDownLatch(5) - CountDownLatch exceptionLatch = new CountDownLatch(1) + List completions = [] + def mapper = new Function>() { @Override CompletionStage apply(Integer integer) { - if (integer == 5) { - return CompletableFuture.supplyAsync { - exceptionLatch.await(10_000, TimeUnit.SECONDS) - sleep(100) - throw new RuntimeException("Bang") - } + if (integer < 5) { + def cf = new CompletableFuture() + completions.add({ cf.complete(String.valueOf(integer)) }) + return cf + } else if (integer == 5) { + def cf = new CompletableFuture() + completions.add({ cf.completeExceptionally(new RuntimeException("Bang")) }) + return cf } else if (integer == 6) { - return CompletableFuture.supplyAsync { - latch.countDown() - exceptionLatch.countDown() // number 5 is now alive - return String.valueOf(integer) + // complete 5,4,3,2,1 so we have to queue + def reverse = completions.reverse() + for (Runnable r : (reverse)) { + r.run() } + return CompletableFuture.completedFuture(String.valueOf(integer)) } else { - return CompletableFuture.supplyAsync { - latch.countDown() - latch.await(10_000, TimeUnit.SECONDS) - return String.valueOf(integer) - } + return CompletableFuture.completedFuture(String.valueOf(integer)) } } } From f7098df0db4835ef85b3f9563b96e0a1af35143d Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Wed, 24 Apr 2024 11:57:24 +1000 Subject: [PATCH 06/67] This has a fix to buffer published events to keep them in order - split out the different publishers and also took out the subscribers --- ...ompletionStageMappingOrderedPublisher.java | 52 ++++ .../CompletionStageMappingPublisher.java | 191 +----------- .../CompletionStageOrderedSubscriber.java | 89 ++++++ .../reactive/CompletionStageSubscriber.java | 169 +++++++++++ ...ionStageMappingOrderedPublisherTest.groovy | 280 ++++++++++++++++++ ...CompletionStageMappingPublisherTest.groovy | 135 +-------- ...sherRandomCompleteTckVerificationTest.java | 71 +++++ ...ngOrderedPublisherTckVerificationTest.java | 50 ++++ ...sherRandomCompleteTckVerificationTest.java | 3 +- ...geMappingPublisherTckVerificationTest.java | 3 +- ...ubscriberPublisherTckVerificationTest.java | 3 +- 11 files changed, 726 insertions(+), 320 deletions(-) create mode 100644 src/main/java/graphql/execution/reactive/CompletionStageMappingOrderedPublisher.java create mode 100644 src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java create mode 100644 src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java create mode 100644 src/test/groovy/graphql/execution/reactive/CompletionStageMappingOrderedPublisherTest.groovy create mode 100644 src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingOrderedPublisherRandomCompleteTckVerificationTest.java create mode 100644 src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingOrderedPublisherTckVerificationTest.java rename src/test/groovy/graphql/execution/reactive/{ => tck}/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java (95%) rename src/test/groovy/graphql/execution/reactive/{ => tck}/CompletionStageMappingPublisherTckVerificationTest.java (93%) rename src/test/groovy/graphql/execution/reactive/{ => tck}/SingleSubscriberPublisherTckVerificationTest.java (92%) diff --git a/src/main/java/graphql/execution/reactive/CompletionStageMappingOrderedPublisher.java b/src/main/java/graphql/execution/reactive/CompletionStageMappingOrderedPublisher.java new file mode 100644 index 0000000000..ad4d6f1cf9 --- /dev/null +++ b/src/main/java/graphql/execution/reactive/CompletionStageMappingOrderedPublisher.java @@ -0,0 +1,52 @@ +package graphql.execution.reactive; + +import graphql.Internal; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; + +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +import static graphql.Assert.assertNotNullWithNPE; + +/** + * A reactive Publisher that bridges over another Publisher of `D` and maps the results + * to type `U` via a CompletionStage, handling errors in that stage but keeps the results + * in order of downstream publishing. This means it must queue unfinished + * completion stages in memory in arrival order. + * + * @param the downstream type + * @param the upstream type to be mapped to + */ +@Internal +public class CompletionStageMappingOrderedPublisher implements Publisher { + private final Publisher upstreamPublisher; + private final Function> mapper; + + /** + * You need the following : + * + * @param upstreamPublisher an upstream source of data + * @param mapper a mapper function that turns upstream data into a promise of mapped D downstream data + */ + public CompletionStageMappingOrderedPublisher(Publisher upstreamPublisher, Function> mapper) { + this.upstreamPublisher = upstreamPublisher; + this.mapper = mapper; + } + + @Override + public void subscribe(Subscriber downstreamSubscriber) { + assertNotNullWithNPE(downstreamSubscriber, () -> "Subscriber passed to subscribe must not be null"); + upstreamPublisher.subscribe(new CompletionStageOrderedSubscriber<>(mapper, downstreamSubscriber)); + } + + /** + * Get instance of an upstreamPublisher + * + * @return upstream instance of {@link Publisher} + */ + public Publisher getUpstreamPublisher() { + return upstreamPublisher; + } + +} diff --git a/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java b/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java index 9970bf54d2..243c8d5258 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java @@ -1,31 +1,19 @@ package graphql.execution.reactive; import graphql.Internal; -import graphql.util.LockKit; import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import java.util.ArrayDeque; -import java.util.Queue; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.BiConsumer; import java.util.function.Function; -import static graphql.Assert.assertNotNullWithNPE; - /** * A reactive Publisher that bridges over another Publisher of `D` and maps the results * to type `U` via a CompletionStage, handling errors in that stage * - * @param the down stream type - * @param the up stream type to be mapped to + * @param the downstream type + * @param the upstream type to be mapped to */ -@SuppressWarnings("ReactiveStreamsPublisherImplementation") @Internal public class CompletionStageMappingPublisher implements Publisher { private final Publisher upstreamPublisher; @@ -44,8 +32,7 @@ public CompletionStageMappingPublisher(Publisher upstreamPublisher, Function< @Override public void subscribe(Subscriber downstreamSubscriber) { - assertNotNullWithNPE(downstreamSubscriber, () -> "Subscriber passed to subscribe must not be null"); - upstreamPublisher.subscribe(new CompletionStageSubscriber(downstreamSubscriber)); + upstreamPublisher.subscribe(new CompletionStageSubscriber<>(mapper, downstreamSubscriber)); } /** @@ -57,176 +44,4 @@ public Publisher getUpstreamPublisher() { return upstreamPublisher; } - @SuppressWarnings("ReactiveStreamsSubscriberImplementation") - @Internal - public class CompletionStageSubscriber implements Subscriber { - private final Subscriber downstreamSubscriber; - Subscription delegatingSubscription; - final Queue> inFlightDataQ; - final LockKit.ReentrantLock lock = new LockKit.ReentrantLock(); - final AtomicReference onCompleteOrErrorRun; - final AtomicBoolean onCompleteOrErrorRunCalled; - final AtomicBoolean subscriptionCancelled; - - public CompletionStageSubscriber(Subscriber downstreamSubscriber) { - this.downstreamSubscriber = downstreamSubscriber; - inFlightDataQ = new ArrayDeque<>(); - onCompleteOrErrorRun = new AtomicReference<>(); - onCompleteOrErrorRunCalled = new AtomicBoolean(false); - subscriptionCancelled = new AtomicBoolean(false); - } - - - @Override - public void onSubscribe(Subscription subscription) { - delegatingSubscription = new DelegatingSubscription(subscription); - downstreamSubscriber.onSubscribe(delegatingSubscription); - } - - @Override - public void onNext(U u) { - // for safety - no more data after we have called done/error - we should not get this BUT belts and braces - if (onCompleteOrErrorRunCalled.get()) { - return; - } - try { - CompletionStage completionStage = mapper.apply(u); - offerToInFlightQ(completionStage); - completionStage.whenComplete(whenNextFinished()); - } catch (RuntimeException throwable) { - handleThrowable(throwable); - } - } - - private BiConsumer whenNextFinished() { - return (d, throwable) -> { - try { - if (throwable != null) { - handleThrowable(throwable); - } else { - emptyInFlightQueueIfWeCan(); - } - } finally { - Runnable runOnCompleteOrErrorRun = onCompleteOrErrorRun.get(); - boolean empty = inFlightQIsEmpty(); - // - // if the runOnCompleteOrErrorRun runnable is set, the upstream has - // called onError() or onComplete() already, but the CFs have not all completed - // yet, so we have to check whenever a CF completes - // - if (empty && runOnCompleteOrErrorRun != null) { - onCompleteOrErrorRun.set(null); - runOnCompleteOrErrorRun.run(); - } - } - }; - } - - private void emptyInFlightQueueIfWeCan() { - if (subscriptionCancelled.get()) { - return; - } - // done inside a memory lock, so we cant offer new CFs to the queue - // until we have processed any completed ones from the start of - // the queue. - lock.runLocked(() -> { - // - // from the top of the in flight queue, take all the CFs that have - // completed... but stop if they are not done - while (!inFlightDataQ.isEmpty()) { - CompletionStage cs = inFlightDataQ.peek(); - if (cs != null) { - // - CompletableFuture cf = cs.toCompletableFuture(); - if (cf.isDone()) { - // take it off the queue - inFlightDataQ.poll(); - D value; - try { - //noinspection unchecked - value = (D) cf.join(); - } catch (RuntimeException rte) { - // - // if we get an exception while joining on a value, we - // send it into the exception handling and break out - handleThrowable(cfExceptionUnwrap(rte)); - break; - } - downstreamSubscriber.onNext(value); - } else { - // if the CF is not done, then we have to stop processing - // to keep the results in order inside the inFlightQueue - break; - } - } - } - }); - } - - private void handleThrowable(Throwable throwable) { - // only do this once - if (subscriptionCancelled.compareAndSet(false,true)) { - downstreamSubscriber.onError(throwable); - // - // Reactive semantics say that IF an exception happens on a publisher, - // then onError is called and no more messages flow. But since the exception happened - // during the mapping, the upstream publisher does not know about this. - // So we cancel to bring the semantics back together, that is as soon as an exception - // has happened, no more messages flow - // - delegatingSubscription.cancel(); - } - } - - @Override - public void onError(Throwable t) { - onCompleteOrError(() -> { - onCompleteOrErrorRunCalled.set(true); - downstreamSubscriber.onError(t); - }); - } - - @Override - public void onComplete() { - onCompleteOrError(() -> { - onCompleteOrErrorRunCalled.set(true); - downstreamSubscriber.onComplete(); - }); - } - - /** - * Get instance of downstream subscriber - * - * @return {@link Subscriber} - */ - public Subscriber getDownstreamSubscriber() { - return downstreamSubscriber; - } - - private void onCompleteOrError(Runnable doneCodeToRun) { - if (inFlightQIsEmpty()) { - // run right now - doneCodeToRun.run(); - } else { - onCompleteOrErrorRun.set(doneCodeToRun); - } - } - - private void offerToInFlightQ(CompletionStage completionStage) { - lock.runLocked(() -> - inFlightDataQ.offer(completionStage) - ); - } - - private boolean inFlightQIsEmpty() { - return lock.callLocked(inFlightDataQ::isEmpty); - } - - private Throwable cfExceptionUnwrap(Throwable throwable) { - if (throwable instanceof CompletionException & throwable.getCause() != null) { - return throwable.getCause(); - } - return throwable; - } - } } diff --git a/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java b/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java new file mode 100644 index 0000000000..de1d58aade --- /dev/null +++ b/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java @@ -0,0 +1,89 @@ +package graphql.execution.reactive; + +import graphql.Internal; +import org.reactivestreams.Subscriber; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * This subscriber can be used to map between a {@link org.reactivestreams.Publisher} of U + * elements and map them into {@link CompletionStage} of D promises, and it keeps them in the order + * the Publisher provided them. + * + * @param published upstream elements + * @param mapped downstream values + */ +@Internal +public class CompletionStageOrderedSubscriber extends CompletionStageSubscriber implements Subscriber { + public CompletionStageOrderedSubscriber(Function> mapper, Subscriber downstreamSubscriber) { + super(mapper, downstreamSubscriber); + } + + @Override + protected BiConsumer whenNextFinished(CompletionStage completionStage) { + return (d, throwable) -> { + try { + if (throwable != null) { + handleThrowable(throwable); + } else { + emptyInFlightQueueIfWeCan(); + } + } finally { + boolean empty = inFlightQIsEmpty(); + finallyAfterEachPromisesFinishes(empty); + } + }; + } + + private void emptyInFlightQueueIfWeCan() { + if (isTerminated()) { + return; + } + // done inside a memory lock, so we cant offer new CFs to the queue + // until we have processed any completed ones from the start of + // the queue. + lock.runLocked(() -> { + // + // from the top of the in flight queue, take all the CFs that have + // completed... but stop if they are not done + while (!inFlightDataQ.isEmpty()) { + CompletionStage cs = inFlightDataQ.peek(); + if (cs != null) { + // + CompletableFuture cf = cs.toCompletableFuture(); + if (cf.isDone()) { + // take it off the queue + inFlightDataQ.poll(); + D value; + try { + //noinspection unchecked + value = (D) cf.join(); + } catch (RuntimeException rte) { + // + // if we get an exception while joining on a value, we + // send it into the exception handling and break out + handleThrowable(cfExceptionUnwrap(rte)); + break; + } + downstreamSubscriber.onNext(value); + } else { + // if the CF is not done, then we have to stop processing + // to keep the results in order inside the inFlightQueue + break; + } + } + } + }); + } + + private Throwable cfExceptionUnwrap(Throwable throwable) { + if (throwable instanceof CompletionException & throwable.getCause() != null) { + return throwable.getCause(); + } + return throwable; + } +} diff --git a/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java b/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java new file mode 100644 index 0000000000..7c5e2846fb --- /dev/null +++ b/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java @@ -0,0 +1,169 @@ +package graphql.execution.reactive; + +import graphql.Internal; +import graphql.util.LockKit; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * This subscriber can be used to map between a {@link org.reactivestreams.Publisher} of U + * elements and map them into {@link CompletionStage} of D promises. + * + * @param published upstream elements + * @param mapped downstream values + */ +@Internal +public class CompletionStageSubscriber implements Subscriber { + protected final Function> mapper; + protected final Subscriber downstreamSubscriber; + protected Subscription delegatingSubscription; + protected final Queue> inFlightDataQ; + protected final LockKit.ReentrantLock lock = new LockKit.ReentrantLock(); + protected final AtomicReference onCompleteOrErrorRun; + protected final AtomicBoolean isTerminated; + + public CompletionStageSubscriber(Function> mapper, Subscriber downstreamSubscriber) { + this.mapper = mapper; + this.downstreamSubscriber = downstreamSubscriber; + inFlightDataQ = new ArrayDeque<>(); + onCompleteOrErrorRun = new AtomicReference<>(); + isTerminated = new AtomicBoolean(false); + } + + + @Override + public void onSubscribe(Subscription subscription) { + delegatingSubscription = new DelegatingSubscription(subscription); + downstreamSubscriber.onSubscribe(delegatingSubscription); + } + + @Override + public void onNext(U u) { + // for safety - no more data after we have called done/error - we should not get this BUT belts and braces + if (isTerminated()) { + return; + } + try { + CompletionStage completionStage = mapper.apply(u); + offerToInFlightQ(completionStage); + completionStage.whenComplete(whenNextFinished(completionStage)); + } catch (RuntimeException throwable) { + handleThrowable(throwable); + } + } + + /** + * This is called as each mapped {@link CompletionStage} completes with + * a value or exception + * + * @param completionStage the completion stage that has completed + * + * @return a handle function for {@link CompletionStage#whenComplete(BiConsumer)} + */ + protected BiConsumer whenNextFinished(CompletionStage completionStage) { + return (d, throwable) -> { + try { + if (throwable != null) { + handleThrowable(throwable); + } else { + downstreamSubscriber.onNext(d); + } + } finally { + boolean empty = removeFromInFlightQAndCheckIfEmpty(completionStage); + finallyAfterEachPromisesFinishes(empty); + } + }; + } + + protected void finallyAfterEachPromisesFinishes(boolean isInFlightEmpty) { + // + // if the runOnCompleteOrErrorRun runnable is set, the upstream has + // called onError() or onComplete() already, but the CFs have not all completed + // yet, so we have to check whenever a CF completes + // + Runnable runOnCompleteOrErrorRun = onCompleteOrErrorRun.get(); + if (isInFlightEmpty && runOnCompleteOrErrorRun != null) { + onCompleteOrErrorRun.set(null); + runOnCompleteOrErrorRun.run(); + } + } + + protected void handleThrowable(Throwable throwable) { + // only do this once + if (isTerminated.compareAndSet(false, true)) { + downstreamSubscriber.onError(throwable); + // + // Reactive semantics say that IF an exception happens on a publisher, + // then onError is called and no more messages flow. But since the exception happened + // during the mapping, the upstream publisher does not know about this. + // So we cancel to bring the semantics back together, that is as soon as an exception + // has happened, no more messages flow + // + delegatingSubscription.cancel(); + } + } + + @Override + public void onError(Throwable t) { + onCompleteOrError(() -> { + isTerminated.set(true); + downstreamSubscriber.onError(t); + }); + } + + @Override + public void onComplete() { + onCompleteOrError(() -> { + isTerminated.set(true); + downstreamSubscriber.onComplete(); + }); + } + + /** + * Get instance of downstream subscriber + * + * @return {@link Subscriber} + */ + public Subscriber getDownstreamSubscriber() { + return downstreamSubscriber; + } + + private void onCompleteOrError(Runnable doneCodeToRun) { + if (inFlightQIsEmpty()) { + // run right now + doneCodeToRun.run(); + } else { + onCompleteOrErrorRun.set(doneCodeToRun); + } + } + + protected void offerToInFlightQ(CompletionStage completionStage) { + lock.runLocked(() -> + inFlightDataQ.offer(completionStage) + ); + } + + private boolean removeFromInFlightQAndCheckIfEmpty(CompletionStage completionStage) { + // uncontested locks in java are cheap - we don't expect much contention here + return lock.callLocked(() -> { + inFlightDataQ.remove(completionStage); + return inFlightDataQ.isEmpty(); + }); + } + + protected boolean inFlightQIsEmpty() { + return lock.callLocked(inFlightDataQ::isEmpty); + } + + protected boolean isTerminated() { + return isTerminated.get(); + } +} diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingOrderedPublisherTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingOrderedPublisherTest.groovy new file mode 100644 index 0000000000..b8f5cb80af --- /dev/null +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingOrderedPublisherTest.groovy @@ -0,0 +1,280 @@ +package graphql.execution.reactive + +import graphql.TestUtil +import graphql.execution.pubsub.CapturingSubscriber +import io.reactivex.Flowable +import org.awaitility.Awaitility +import org.reactivestreams.Publisher +import spock.lang.Specification + +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionException +import java.util.concurrent.CompletionStage +import java.util.function.Function + +class CompletionStageMappingOrderedPublisherTest extends Specification { + + def "basic mapping"() { + + when: + Publisher rxIntegers = Flowable.range(0, 10) + + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + return CompletableFuture.completedFuture(String.valueOf(integer)) + } + } + Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) + + def capturingSubscriber = new CapturingSubscriber<>() + rxStrings.subscribe(capturingSubscriber) + + then: + + capturingSubscriber.events.size() == 10 + // order is kept + capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + } + + def "multiple subscribers get their messages"() { + + when: + Publisher rxIntegers = Flowable.range(0, 10) + + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + return CompletableFuture.completedFuture(String.valueOf(integer)) + } + } + Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) + + def capturingSubscriber1 = new CapturingSubscriber<>() + def capturingSubscriber2 = new CapturingSubscriber<>() + rxStrings.subscribe(capturingSubscriber1) + rxStrings.subscribe(capturingSubscriber2) + + then: + + capturingSubscriber1.events.size() == 10 + // order is kept + capturingSubscriber1.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + + capturingSubscriber2.events.size() == 10 + // order is kept + capturingSubscriber2.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + } + + def "error handling"() { + when: + Publisher rxIntegers = Flowable.range(0, 10) + + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + + if (integer == 5) { + def future = new CompletableFuture() + future.completeExceptionally(new RuntimeException("Bang")) + return future + } else { + CompletableFuture.completedFuture(String.valueOf(integer)) + } + } + } + Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) + + def capturingSubscriber = new CapturingSubscriber<>() + rxStrings.subscribe(capturingSubscriber) + + then: + + capturingSubscriber.throwable.getMessage() == "Bang" + // + // got this far and cancelled + capturingSubscriber.events.size() == 5 + capturingSubscriber.events == ["0", "1", "2", "3", "4",] + + } + + def "error handling when the error happens in the middle of the processing but before the others complete"() { + when: + Publisher rxIntegers = Flowable.range(0, 10) + + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + + if (integer == 5) { + return CompletableFuture.supplyAsync { + throw new RuntimeException("Bang") + } + } else { + return asyncValueAfterDelay(10, integer) + } + } + } + Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) + + def capturingSubscriber = new CapturingSubscriber<>(10) + rxStrings.subscribe(capturingSubscriber) + + then: + Awaitility.await().untilTrue(capturingSubscriber.isDone()) + + unwrap(capturingSubscriber.throwable).getMessage() == "Bang" + } + + def "error handling when the error happens in the middle of the processing but after the previous ones complete"() { + when: + Publisher rxIntegers = Flowable.range(0, 10) + + List completions = [] + + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + + if (integer < 5) { + def cf = new CompletableFuture() + completions.add({ cf.complete(String.valueOf(integer)) }) + return cf + } else if (integer == 5) { + def cf = new CompletableFuture() + completions.add({ cf.completeExceptionally(new RuntimeException("Bang")) }) + return cf + } else if (integer == 6) { + // complete 5,4,3,2,1 so we have to queue + def reverse = completions.reverse() + for (Runnable r : (reverse)) { + r.run() + } + return CompletableFuture.completedFuture(String.valueOf(integer)) + } else { + return CompletableFuture.completedFuture(String.valueOf(integer)) + } + } + } + Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) + + def capturingSubscriber = new CapturingSubscriber<>(10) + rxStrings.subscribe(capturingSubscriber) + + then: + Awaitility.await().untilTrue(capturingSubscriber.isDone()) + + unwrap(capturingSubscriber.throwable).getMessage() == "Bang" + } + + + def "mapper exception causes onError"() { + when: + Publisher rxIntegers = Flowable.range(0, 10) + + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + + if (integer == 5) { + throw new RuntimeException("Bang") + } else { + CompletableFuture.completedFuture(String.valueOf(integer)) + } + } + } + Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) + + def capturingSubscriber = new CapturingSubscriber<>() + rxStrings.subscribe(capturingSubscriber) + + then: + + capturingSubscriber.throwable.getMessage() == "Bang" + // + // got this far and cancelled + capturingSubscriber.events.size() == 5 + capturingSubscriber.events == ["0", "1", "2", "3", "4",] + } + + + def "asynchronous mapping works with completion"() { + + when: + Publisher rxIntegers = Flowable.range(0, 10) + + Function> mapper = mapperThatDelaysFor(100) + Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) + + def capturingSubscriber = new CapturingSubscriber<>() + rxStrings.subscribe(capturingSubscriber) + + then: + + Awaitility.await().untilTrue(capturingSubscriber.isDone()) + + capturingSubscriber.events.size() == 10 + // order is kept + capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + } + + def "asynchronous mapping works when they complete out of order"() { + + when: + Publisher rxIntegers = Flowable.range(0, 10) + + Function> mapper = mapperThatRandomlyDelaysFor(5, 15) + Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) + + // ask for 10 at a time to create some forward pressure + def capturingSubscriber = new CapturingSubscriber<>(10) + rxStrings.subscribe(capturingSubscriber) + + then: + + Awaitility.await().untilTrue(capturingSubscriber.isDone()) + + capturingSubscriber.events.size() == 10 + // the original flow order was kept + capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + } + + Function> mapperThatDelaysFor(int delay) { + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + return CompletableFuture.supplyAsync({ + Thread.sleep(delay) + println "\t\tcompleted : " + integer + return String.valueOf(integer) + }) + } + } + mapper + } + + Function> mapperThatRandomlyDelaysFor(int delayMin, int delayMax) { + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + return asyncValueAfterDelay(TestUtil.rand(delayMin, delayMax), integer) + } + } + mapper + } + + private static CompletableFuture asyncValueAfterDelay(int delay, int integer) { + return CompletableFuture.supplyAsync({ + Thread.sleep(delay) + println "\t\tcompleted : " + integer + return String.valueOf(integer) + }) + } + + private static Throwable unwrap(Throwable throwable) { + if (throwable instanceof CompletionException) { + return ((CompletionException) throwable).getCause() + } + return throwable + } +} diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy index 891871b83f..c2117f9a8d 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy @@ -1,6 +1,5 @@ package graphql.execution.reactive -import graphql.TestUtil import graphql.execution.pubsub.CapturingSubscriber import io.reactivex.Flowable import org.awaitility.Awaitility @@ -8,7 +7,6 @@ import org.reactivestreams.Publisher import spock.lang.Specification import java.util.concurrent.CompletableFuture -import java.util.concurrent.CompletionException import java.util.concurrent.CompletionStage import java.util.function.Function @@ -33,11 +31,11 @@ class CompletionStageMappingPublisherTest extends Specification { then: capturingSubscriber.events.size() == 10 - // order is kept - capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + capturingSubscriber.events[0] instanceof String + capturingSubscriber.events[0] == "0" } - def "multiple subscribers get their messages"() { + def "multiple subscribers get there messages"() { when: Publisher rxIntegers = Flowable.range(0, 10) @@ -58,12 +56,7 @@ class CompletionStageMappingPublisherTest extends Specification { then: capturingSubscriber1.events.size() == 10 - // order is kept - capturingSubscriber1.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] - capturingSubscriber2.events.size() == 10 - // order is kept - capturingSubscriber2.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] } def "error handling"() { @@ -94,77 +87,7 @@ class CompletionStageMappingPublisherTest extends Specification { // // got this far and cancelled capturingSubscriber.events.size() == 5 - capturingSubscriber.events == ["0", "1", "2", "3", "4",] - - } - - def "error handling when the error happens in the middle of the processing but before the others complete"() { - when: - Publisher rxIntegers = Flowable.range(0, 10) - - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - - if (integer == 5) { - return CompletableFuture.supplyAsync { - throw new RuntimeException("Bang") - } - } else { - return asyncValueAfterDelay(10, integer) - } - } - } - Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) - - def capturingSubscriber = new CapturingSubscriber<>(10) - rxStrings.subscribe(capturingSubscriber) - then: - Awaitility.await().untilTrue(capturingSubscriber.isDone()) - - unwrap(capturingSubscriber.throwable).getMessage() == "Bang" - } - - def "error handling when the error happens in the middle of the processing but after the previous ones complete"() { - when: - Publisher rxIntegers = Flowable.range(0, 10) - - List completions = [] - - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - - if (integer < 5) { - def cf = new CompletableFuture() - completions.add({ cf.complete(String.valueOf(integer)) }) - return cf - } else if (integer == 5) { - def cf = new CompletableFuture() - completions.add({ cf.completeExceptionally(new RuntimeException("Bang")) }) - return cf - } else if (integer == 6) { - // complete 5,4,3,2,1 so we have to queue - def reverse = completions.reverse() - for (Runnable r : (reverse)) { - r.run() - } - return CompletableFuture.completedFuture(String.valueOf(integer)) - } else { - return CompletableFuture.completedFuture(String.valueOf(integer)) - } - } - } - Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) - - def capturingSubscriber = new CapturingSubscriber<>(10) - rxStrings.subscribe(capturingSubscriber) - - then: - Awaitility.await().untilTrue(capturingSubscriber.isDone()) - - unwrap(capturingSubscriber.throwable).getMessage() == "Bang" } @@ -194,7 +117,7 @@ class CompletionStageMappingPublisherTest extends Specification { // // got this far and cancelled capturingSubscriber.events.size() == 5 - capturingSubscriber.events == ["0", "1", "2", "3", "4",] + } @@ -214,29 +137,8 @@ class CompletionStageMappingPublisherTest extends Specification { Awaitility.await().untilTrue(capturingSubscriber.isDone()) capturingSubscriber.events.size() == 10 - // order is kept - capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] - } - - def "asynchronous mapping works when they complete out of order"() { - - when: - Publisher rxIntegers = Flowable.range(0, 10) - - Function> mapper = mapperThatRandomlyDelaysFor(5, 15) - Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) - - // ask for 10 at a time to create some forward pressure - def capturingSubscriber = new CapturingSubscriber<>(10) - rxStrings.subscribe(capturingSubscriber) - - then: - - Awaitility.await().untilTrue(capturingSubscriber.isDone()) - - capturingSubscriber.events.size() == 10 - // the original flow order was kept - capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + capturingSubscriber.events[0] instanceof String + capturingSubscriber.events[0] == "0" } Function> mapperThatDelaysFor(int delay) { @@ -245,7 +147,6 @@ class CompletionStageMappingPublisherTest extends Specification { CompletionStage apply(Integer integer) { return CompletableFuture.supplyAsync({ Thread.sleep(delay) - println "\t\tcompleted : " + integer return String.valueOf(integer) }) } @@ -253,28 +154,4 @@ class CompletionStageMappingPublisherTest extends Specification { mapper } - Function> mapperThatRandomlyDelaysFor(int delayMin, int delayMax) { - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - return asyncValueAfterDelay(TestUtil.rand(delayMin, delayMax), integer) - } - } - mapper - } - - private static CompletableFuture asyncValueAfterDelay(int delay, int integer) { - return CompletableFuture.supplyAsync({ - Thread.sleep(delay) - println "\t\tcompleted : " + integer - return String.valueOf(integer) - }) - } - - private static Throwable unwrap(Throwable throwable) { - if (throwable instanceof CompletionException) { - return ((CompletionException) throwable).getCause() - } - return throwable - } } diff --git a/src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingOrderedPublisherRandomCompleteTckVerificationTest.java b/src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingOrderedPublisherRandomCompleteTckVerificationTest.java new file mode 100644 index 0000000000..aaaa46c9d3 --- /dev/null +++ b/src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingOrderedPublisherRandomCompleteTckVerificationTest.java @@ -0,0 +1,71 @@ +package graphql.execution.reactive.tck; + +import graphql.execution.reactive.CompletionStageMappingOrderedPublisher; +import graphql.execution.reactive.CompletionStageMappingPublisher; +import io.reactivex.Flowable; +import org.jetbrains.annotations.NotNull; +import org.reactivestreams.Publisher; +import org.reactivestreams.tck.PublisherVerification; +import org.reactivestreams.tck.TestEnvironment; +import org.testng.annotations.Test; + +import java.time.Duration; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +/** + * This uses the reactive streams TCK to test that our CompletionStageMappingPublisher meets spec + * when it's got CFs that complete at different times + */ +@Test +public class CompletionStageMappingOrderedPublisherRandomCompleteTckVerificationTest extends PublisherVerification { + + public CompletionStageMappingOrderedPublisherRandomCompleteTckVerificationTest() { + super(new TestEnvironment(Duration.ofMillis(100).toMillis())); + } + + @Override + public long maxElementsFromPublisher() { + return 10000; + } + + @Override + public Publisher createPublisher(long elements) { + Publisher publisher = Flowable.range(0, (int) elements); + Function> mapper = mapperFunc(); + return new CompletionStageMappingOrderedPublisher<>(publisher, mapper); + } + @Override + public Publisher createFailedPublisher() { + Publisher publisher = Flowable.error(() -> new RuntimeException("Bang")); + Function> mapper = mapperFunc(); + return new CompletionStageMappingOrderedPublisher<>(publisher, mapper); + } + + public boolean skipStochasticTests() { + return true; + } + + @NotNull + private static Function> mapperFunc() { + return i -> CompletableFuture.supplyAsync(() -> { + int ms = rand(0, 5); + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return i + "!"; + }); + } + + static Random rn = new Random(); + + private static int rand(int min, int max) { + return rn.nextInt(max - min + 1) + min; + } + +} + diff --git a/src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingOrderedPublisherTckVerificationTest.java b/src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingOrderedPublisherTckVerificationTest.java new file mode 100644 index 0000000000..03a9a7fe86 --- /dev/null +++ b/src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingOrderedPublisherTckVerificationTest.java @@ -0,0 +1,50 @@ +package graphql.execution.reactive.tck; + +import graphql.execution.reactive.CompletionStageMappingOrderedPublisher; +import io.reactivex.Flowable; +import org.reactivestreams.Publisher; +import org.reactivestreams.tck.PublisherVerification; +import org.reactivestreams.tck.TestEnvironment; +import org.testng.annotations.Test; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +/** + * This uses the reactive streams TCK to test that our CompletionStageMappingPublisher meets spec + * when it's got CFs that complete off thread + */ +@Test +public class CompletionStageMappingOrderedPublisherTckVerificationTest extends PublisherVerification { + + public CompletionStageMappingOrderedPublisherTckVerificationTest() { + super(new TestEnvironment(Duration.ofMillis(1000).toMillis())); + } + + @Override + public long maxElementsFromPublisher() { + return 10000; + } + + @Override + public Publisher createPublisher(long elements) { + Publisher publisher = Flowable.range(0, (int) elements); + Function> mapper = i -> CompletableFuture.supplyAsync(() -> i + "!"); + return new CompletionStageMappingOrderedPublisher<>(publisher, mapper); + } + + @Override + public Publisher createFailedPublisher() { + Publisher publisher = Flowable.error(() -> new RuntimeException("Bang")); + Function> mapper = i -> CompletableFuture.supplyAsync(() -> i + "!"); + return new CompletionStageMappingOrderedPublisher<>(publisher, mapper); + } + + @Override + public boolean skipStochasticTests() { + return true; + } +} + diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java b/src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java similarity index 95% rename from src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java rename to src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java index dba025be26..2455b3ee44 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java +++ b/src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingPublisherRandomCompleteTckVerificationTest.java @@ -1,5 +1,6 @@ -package graphql.execution.reactive; +package graphql.execution.reactive.tck; +import graphql.execution.reactive.CompletionStageMappingPublisher; import io.reactivex.Flowable; import org.jetbrains.annotations.NotNull; import org.reactivestreams.Publisher; diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java b/src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingPublisherTckVerificationTest.java similarity index 93% rename from src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java rename to src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingPublisherTckVerificationTest.java index 39a4fa8bf7..9c58e3fd6e 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTckVerificationTest.java +++ b/src/test/groovy/graphql/execution/reactive/tck/CompletionStageMappingPublisherTckVerificationTest.java @@ -1,5 +1,6 @@ -package graphql.execution.reactive; +package graphql.execution.reactive.tck; +import graphql.execution.reactive.CompletionStageMappingPublisher; import io.reactivex.Flowable; import org.reactivestreams.Publisher; import org.reactivestreams.tck.PublisherVerification; diff --git a/src/test/groovy/graphql/execution/reactive/SingleSubscriberPublisherTckVerificationTest.java b/src/test/groovy/graphql/execution/reactive/tck/SingleSubscriberPublisherTckVerificationTest.java similarity index 92% rename from src/test/groovy/graphql/execution/reactive/SingleSubscriberPublisherTckVerificationTest.java rename to src/test/groovy/graphql/execution/reactive/tck/SingleSubscriberPublisherTckVerificationTest.java index 59d69e4162..472cbaa357 100644 --- a/src/test/groovy/graphql/execution/reactive/SingleSubscriberPublisherTckVerificationTest.java +++ b/src/test/groovy/graphql/execution/reactive/tck/SingleSubscriberPublisherTckVerificationTest.java @@ -1,5 +1,6 @@ -package graphql.execution.reactive; +package graphql.execution.reactive.tck; +import graphql.execution.reactive.SingleSubscriberPublisher; import org.reactivestreams.Publisher; import org.reactivestreams.tck.PublisherVerification; import org.reactivestreams.tck.TestEnvironment; From db46b1d045627f97610e35665f1f3de5ee47937f Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Wed, 24 Apr 2024 16:00:55 +1000 Subject: [PATCH 07/67] This has a fix to buffer published events to keep them in order - more tests and more code tweaks --- .../CompletionStageOrderedSubscriber.java | 29 ++- .../reactive/CompletionStageSubscriber.java | 104 +++++----- .../execution/pubsub/CapturingSubscriber.java | 7 + .../pubsub/CapturingSubscription.java | 26 +++ ...ompletionStageOrderedSubscriberTest.groovy | 184 ++++++++++++++++++ .../CompletionStageSubscriberTest.groovy | 179 +++++++++++++++++ 6 files changed, 467 insertions(+), 62 deletions(-) create mode 100644 src/test/groovy/graphql/execution/pubsub/CapturingSubscription.java create mode 100644 src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy create mode 100644 src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy diff --git a/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java b/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java index de1d58aade..855dcc6df9 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java @@ -6,7 +6,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; -import java.util.function.BiConsumer; import java.util.function.Function; /** @@ -19,30 +18,26 @@ */ @Internal public class CompletionStageOrderedSubscriber extends CompletionStageSubscriber implements Subscriber { + public CompletionStageOrderedSubscriber(Function> mapper, Subscriber downstreamSubscriber) { super(mapper, downstreamSubscriber); } @Override - protected BiConsumer whenNextFinished(CompletionStage completionStage) { - return (d, throwable) -> { - try { - if (throwable != null) { - handleThrowable(throwable); - } else { - emptyInFlightQueueIfWeCan(); - } - } finally { - boolean empty = inFlightQIsEmpty(); - finallyAfterEachPromisesFinishes(empty); + protected void whenNextFinished(CompletionStage completionStage, D d, Throwable throwable) { + try { + if (throwable != null) { + handleThrowableDuringMapping(throwable); + } else { + emptyInFlightQueueIfWeCan(); } - }; + } finally { + boolean empty = inFlightQIsEmpty(); + finallyAfterEachPromisesFinishes(empty); + } } private void emptyInFlightQueueIfWeCan() { - if (isTerminated()) { - return; - } // done inside a memory lock, so we cant offer new CFs to the queue // until we have processed any completed ones from the start of // the queue. @@ -66,7 +61,7 @@ private void emptyInFlightQueueIfWeCan() { // // if we get an exception while joining on a value, we // send it into the exception handling and break out - handleThrowable(cfExceptionUnwrap(rte)); + handleThrowableDuringMapping(cfExceptionUnwrap(rte)); break; } downstreamSubscriber.onNext(value); diff --git a/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java b/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java index 7c5e2846fb..975362a6b6 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java @@ -2,6 +2,7 @@ import graphql.Internal; import graphql.util.LockKit; +import org.jetbrains.annotations.NotNull; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; @@ -27,17 +28,25 @@ public class CompletionStageSubscriber implements Subscriber { protected Subscription delegatingSubscription; protected final Queue> inFlightDataQ; protected final LockKit.ReentrantLock lock = new LockKit.ReentrantLock(); - protected final AtomicReference onCompleteOrErrorRun; - protected final AtomicBoolean isTerminated; + protected final AtomicReference onCompleteRun; + protected final AtomicBoolean isTerminal; public CompletionStageSubscriber(Function> mapper, Subscriber downstreamSubscriber) { this.mapper = mapper; this.downstreamSubscriber = downstreamSubscriber; inFlightDataQ = new ArrayDeque<>(); - onCompleteOrErrorRun = new AtomicReference<>(); - isTerminated = new AtomicBoolean(false); + onCompleteRun = new AtomicReference<>(); + isTerminal = new AtomicBoolean(false); } + /** + * Get instance of downstream subscriber + * + * @return {@link Subscriber} + */ + public Subscriber getDownstreamSubscriber() { + return downstreamSubscriber; + } @Override public void onSubscribe(Subscription subscription) { @@ -48,57 +57,65 @@ public void onSubscribe(Subscription subscription) { @Override public void onNext(U u) { // for safety - no more data after we have called done/error - we should not get this BUT belts and braces - if (isTerminated()) { + if (isTerminal()) { return; } try { CompletionStage completionStage = mapper.apply(u); offerToInFlightQ(completionStage); - completionStage.whenComplete(whenNextFinished(completionStage)); + completionStage.whenComplete(whenComplete(completionStage)); } catch (RuntimeException throwable) { - handleThrowable(throwable); + handleThrowableDuringMapping(throwable); } } + @NotNull + private BiConsumer whenComplete(CompletionStage completionStage) { + return (d, throwable) -> { + if (isTerminal()) { + return; + } + whenNextFinished(completionStage, d, throwable); + }; + } + /** * This is called as each mapped {@link CompletionStage} completes with * a value or exception * * @param completionStage the completion stage that has completed - * - * @return a handle function for {@link CompletionStage#whenComplete(BiConsumer)} + * @param d the value completed + * @param throwable or the throwable that happened during completion */ - protected BiConsumer whenNextFinished(CompletionStage completionStage) { - return (d, throwable) -> { - try { - if (throwable != null) { - handleThrowable(throwable); - } else { - downstreamSubscriber.onNext(d); - } - } finally { - boolean empty = removeFromInFlightQAndCheckIfEmpty(completionStage); - finallyAfterEachPromisesFinishes(empty); + protected void whenNextFinished(CompletionStage completionStage, D d, Throwable throwable) { + try { + if (throwable != null) { + handleThrowableDuringMapping(throwable); + } else { + downstreamSubscriber.onNext(d); } - }; + } finally { + boolean empty = removeFromInFlightQAndCheckIfEmpty(completionStage); + finallyAfterEachPromisesFinishes(empty); + } } protected void finallyAfterEachPromisesFinishes(boolean isInFlightEmpty) { // // if the runOnCompleteOrErrorRun runnable is set, the upstream has - // called onError() or onComplete() already, but the CFs have not all completed + // called onComplete() already, but the CFs have not all completed // yet, so we have to check whenever a CF completes // - Runnable runOnCompleteOrErrorRun = onCompleteOrErrorRun.get(); + Runnable runOnCompleteOrErrorRun = onCompleteRun.get(); if (isInFlightEmpty && runOnCompleteOrErrorRun != null) { - onCompleteOrErrorRun.set(null); + onCompleteRun.set(null); runOnCompleteOrErrorRun.run(); } } - protected void handleThrowable(Throwable throwable) { + protected void handleThrowableDuringMapping(Throwable throwable) { // only do this once - if (isTerminated.compareAndSet(false, true)) { + if (isTerminal.compareAndSet(false, true)) { downstreamSubscriber.onError(throwable); // // Reactive semantics say that IF an exception happens on a publisher, @@ -113,35 +130,27 @@ protected void handleThrowable(Throwable throwable) { @Override public void onError(Throwable t) { - onCompleteOrError(() -> { - isTerminated.set(true); + // we immediately terminate - we don't wait for any promises to complete + if (isTerminal.compareAndSet(false, true)) { downstreamSubscriber.onError(t); - }); + } } @Override public void onComplete() { - onCompleteOrError(() -> { - isTerminated.set(true); - downstreamSubscriber.onComplete(); + onComplete(() -> { + if (isTerminal.compareAndSet(false, true)) { + downstreamSubscriber.onComplete(); + } }); } - /** - * Get instance of downstream subscriber - * - * @return {@link Subscriber} - */ - public Subscriber getDownstreamSubscriber() { - return downstreamSubscriber; - } - - private void onCompleteOrError(Runnable doneCodeToRun) { + private void onComplete(Runnable doneCodeToRun) { if (inFlightQIsEmpty()) { // run right now doneCodeToRun.run(); } else { - onCompleteOrErrorRun.set(doneCodeToRun); + onCompleteRun.set(doneCodeToRun); } } @@ -163,7 +172,12 @@ protected boolean inFlightQIsEmpty() { return lock.callLocked(inFlightDataQ::isEmpty); } - protected boolean isTerminated() { - return isTerminated.get(); + /** + * The two terminal states are onComplete or onError + * + * @return true if it's in a terminal state + */ + protected boolean isTerminal() { + return isTerminal.get(); } } diff --git a/src/test/groovy/graphql/execution/pubsub/CapturingSubscriber.java b/src/test/groovy/graphql/execution/pubsub/CapturingSubscriber.java index 376bb3e774..790bac22be 100644 --- a/src/test/groovy/graphql/execution/pubsub/CapturingSubscriber.java +++ b/src/test/groovy/graphql/execution/pubsub/CapturingSubscriber.java @@ -74,6 +74,13 @@ public AtomicBoolean isDone() { return done; } + public boolean isCompleted() { + return done.get() && throwable == null; + } + public boolean isCompletedExceptionally() { + return done.get() && throwable != null; + } + public Subscription getSubscription() { return subscription; } diff --git a/src/test/groovy/graphql/execution/pubsub/CapturingSubscription.java b/src/test/groovy/graphql/execution/pubsub/CapturingSubscription.java new file mode 100644 index 0000000000..551a34a119 --- /dev/null +++ b/src/test/groovy/graphql/execution/pubsub/CapturingSubscription.java @@ -0,0 +1,26 @@ +package graphql.execution.pubsub; + +import org.reactivestreams.Subscription; + +public class CapturingSubscription implements Subscription { + private long count = 0; + private boolean cancelled = false; + + public long getCount() { + return count; + } + + public boolean isCancelled() { + return cancelled; + } + + @Override + public void request(long l) { + count += l; + } + + @Override + public void cancel() { + cancelled = true; + } +} diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy new file mode 100644 index 0000000000..2f8a69c260 --- /dev/null +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy @@ -0,0 +1,184 @@ +package graphql.execution.reactive + +import graphql.execution.pubsub.CapturingSubscriber +import graphql.execution.pubsub.CapturingSubscription +import spock.lang.Specification + +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.function.Function + +class CompletionStageOrderedSubscriberTest extends Specification { + + def "basic test of mapping"() { + def capturingSubscriber = new CapturingSubscriber<>() + def subscription = new CapturingSubscription() + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + return CompletableFuture.completedFuture(String.valueOf(integer)) + } + } + + when: + def completionStageSubscriber = new CompletionStageOrderedSubscriber(mapper, capturingSubscriber) + + then: + completionStageSubscriber.getDownstreamSubscriber() == capturingSubscriber + + when: + completionStageSubscriber.onSubscribe(subscription) + completionStageSubscriber.onNext(0) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == ["0"] + + when: + completionStageSubscriber.onNext(1) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == ["0", "1"] + + when: + completionStageSubscriber.onComplete() + + then: + !subscription.isCancelled() + capturingSubscriber.isCompleted() + capturingSubscriber.events == ["0", "1"] + } + + def "can hold CFs that have not completed and does not emit them even when onComplete is called"() { + def capturingSubscriber = new CapturingSubscriber<>() + def subscription = new CapturingSubscription() + List promises = [] + Function> mapper = mapperThatDoesNotComplete(promises) + def completionStageSubscriber = new CompletionStageOrderedSubscriber(mapper, capturingSubscriber) + + when: + completionStageSubscriber.onSubscribe(subscription) + completionStageSubscriber.onNext(0) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + completionStageSubscriber.onNext(1) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + completionStageSubscriber.onComplete() + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + promises.forEach { it.run() } + + then: + !subscription.isCancelled() + capturingSubscriber.isCompleted() + capturingSubscriber.events == ["0", "1"] + } + + def "can hold CFs that have not completed but finishes quickly when onError is called"() { + def capturingSubscriber = new CapturingSubscriber<>() + def subscription = new CapturingSubscription() + List promises = [] + Function> mapper = mapperThatDoesNotComplete(promises) + def completionStageSubscriber = new CompletionStageOrderedSubscriber(mapper, capturingSubscriber) + + when: + completionStageSubscriber.onSubscribe(subscription) + completionStageSubscriber.onNext(0) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + completionStageSubscriber.onNext(1) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + completionStageSubscriber.onError(new RuntimeException("Bang")) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.isCompletedExceptionally() + // it immediately errored out + capturingSubscriber.getThrowable().getMessage() == "Bang" + capturingSubscriber.events == [] + + when: + // even if the promises later complete we are done + promises.forEach { it.run() } + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.isCompletedExceptionally() + capturingSubscriber.getThrowable().getMessage() == "Bang" + capturingSubscriber.events == [] + } + + def "emits values in the order they arrive not the order they complete"() { + def capturingSubscriber = new CapturingSubscriber<>() + def subscription = new CapturingSubscription() + List promises = [] + Function> mapper = mapperThatDoesNotComplete(promises) + def completionStageSubscriber = new CompletionStageOrderedSubscriber(mapper, capturingSubscriber) + + when: + completionStageSubscriber.onSubscribe(subscription) + completionStageSubscriber.onNext(0) + completionStageSubscriber.onNext(1) + completionStageSubscriber.onNext(2) + completionStageSubscriber.onNext(3) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + completionStageSubscriber.onComplete() + promises.reverse().forEach { it.run() } + + then: + !subscription.isCancelled() + capturingSubscriber.isCompleted() + capturingSubscriber.events == ["0","1","2","3"] + } + + private static Function> mapperThatDoesNotComplete(List promises) { + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + def cf = new CompletableFuture() + promises.add({ cf.complete(String.valueOf(integer)) }) + return cf + } + } + mapper + } + +} diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy new file mode 100644 index 0000000000..fe103034fb --- /dev/null +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy @@ -0,0 +1,179 @@ +package graphql.execution.reactive + +import graphql.execution.pubsub.CapturingSubscriber +import graphql.execution.pubsub.CapturingSubscription +import spock.lang.Specification + +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.function.Function + +class CompletionStageSubscriberTest extends Specification { + + def "basic test of mapping"() { + def capturingSubscriber = new CapturingSubscriber<>() + def subscription = new CapturingSubscription() + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + return CompletableFuture.completedFuture(String.valueOf(integer)) + } + } + def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + + when: + completionStageSubscriber.onSubscribe(subscription) + completionStageSubscriber.onNext(0) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == ["0"] + + when: + completionStageSubscriber.onNext(1) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == ["0", "1"] + + when: + completionStageSubscriber.onComplete() + + then: + !subscription.isCancelled() + capturingSubscriber.isCompleted() + capturingSubscriber.events == ["0", "1"] + } + + def "can hold CFs that have not completed and does not emit them even when onComplete is called"() { + def capturingSubscriber = new CapturingSubscriber<>() + def subscription = new CapturingSubscription() + List promises = [] + Function> mapper = mapperThatDoesNotComplete(promises) + def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + + when: + completionStageSubscriber.onSubscribe(subscription) + completionStageSubscriber.onNext(0) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + completionStageSubscriber.onNext(1) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + completionStageSubscriber.onComplete() + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + promises.forEach { it.run() } + + then: + !subscription.isCancelled() + capturingSubscriber.isCompleted() + capturingSubscriber.events == ["0", "1"] + } + + def "can hold CFs that have not completed but finishes quickly when onError is called"() { + def capturingSubscriber = new CapturingSubscriber<>() + def subscription = new CapturingSubscription() + List promises = [] + Function> mapper = mapperThatDoesNotComplete(promises) + def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + + when: + completionStageSubscriber.onSubscribe(subscription) + completionStageSubscriber.onNext(0) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + completionStageSubscriber.onNext(1) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + completionStageSubscriber.onError(new RuntimeException("Bang")) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.isCompletedExceptionally() + // it immediately errored out + capturingSubscriber.getThrowable().getMessage() == "Bang" + capturingSubscriber.events == [] + + when: + // even if the promises later complete we are done + promises.forEach { it.run() } + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.isCompletedExceptionally() + capturingSubscriber.getThrowable().getMessage() == "Bang" + capturingSubscriber.events == [] + } + + def "emits values in the order they complete not how arrive"() { + def capturingSubscriber = new CapturingSubscriber<>() + def subscription = new CapturingSubscription() + List promises = [] + Function> mapper = mapperThatDoesNotComplete(promises) + def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + + when: + completionStageSubscriber.onSubscribe(subscription) + completionStageSubscriber.onNext(0) + completionStageSubscriber.onNext(1) + completionStageSubscriber.onNext(2) + completionStageSubscriber.onNext(3) + + then: + !subscription.isCancelled() + !capturingSubscriber.isCompleted() + capturingSubscriber.events == [] + + when: + completionStageSubscriber.onComplete() + promises.reverse().forEach { it.run() } + + then: + !subscription.isCancelled() + capturingSubscriber.isCompleted() + capturingSubscriber.events == ["3","2","1","0"] + } + + private static Function> mapperThatDoesNotComplete(List promises) { + def mapper = new Function>() { + @Override + CompletionStage apply(Integer integer) { + def cf = new CompletableFuture() + promises.add({ cf.complete(String.valueOf(integer)) }) + return cf + } + } + mapper + } + +} From 92713a51c2239483fc20251b5b53230432415c50 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Wed, 24 Apr 2024 21:02:47 +1000 Subject: [PATCH 08/67] This has a fix to buffer published events to keep them in order - the subscribers now inhrent from each other and outstanding futures are now cancelled if the Publisher fails --- .../SubscriptionExecutionStrategy.java | 18 +++- ...ompletionStageMappingOrderedPublisher.java | 26 +----- .../CompletionStageMappingPublisher.java | 16 +++- .../reactive/CompletionStageSubscriber.java | 18 ++++ .../reactive/SubscriptionPublisher.java | 9 +- .../SubscriptionExecutionStrategyTest.groovy | 93 +++++++++++++++++++ ...ompletionStageOrderedSubscriberTest.groovy | 41 +++++--- .../CompletionStageSubscriberTest.groovy | 32 ++++++- .../SubscriptionReproduction.java | 14 ++- 9 files changed, 222 insertions(+), 45 deletions(-) diff --git a/src/main/java/graphql/execution/SubscriptionExecutionStrategy.java b/src/main/java/graphql/execution/SubscriptionExecutionStrategy.java index 7a7cd5c952..4cc43963b8 100644 --- a/src/main/java/graphql/execution/SubscriptionExecutionStrategy.java +++ b/src/main/java/graphql/execution/SubscriptionExecutionStrategy.java @@ -2,6 +2,7 @@ import graphql.ExecutionResult; import graphql.ExecutionResultImpl; +import graphql.GraphQLContext; import graphql.PublicApi; import graphql.execution.instrumentation.ExecutionStrategyInstrumentationContext; import graphql.execution.instrumentation.Instrumentation; @@ -37,6 +38,14 @@ @PublicApi public class SubscriptionExecutionStrategy extends ExecutionStrategy { + /** + * If a boolean value is placed into the {@link GraphQLContext} with this key then the order + * of the subscription events can be controlled. By default, subscription events are published + * as the graphql subselection calls complete, and not in the order they originally arrived from the + * source publisher. But this can be changed to {@link Boolean#TRUE} to keep them in order. + */ + public static final String KEEP_SUBSCRIPTION_EVENTS_ORDERED = "KEEP_SUBSCRIPTION_EVENTS_ORDERED"; + public SubscriptionExecutionStrategy() { super(); } @@ -64,7 +73,8 @@ public CompletableFuture execute(ExecutionContext executionCont return new ExecutionResultImpl(null, executionContext.getErrors()); } Function> mapperFunction = eventPayload -> executeSubscriptionEvent(executionContext, parameters, eventPayload); - SubscriptionPublisher mapSourceToResponse = new SubscriptionPublisher(publisher, mapperFunction); + boolean keepOrdered = keepOrdered(executionContext.getGraphQLContext()); + SubscriptionPublisher mapSourceToResponse = new SubscriptionPublisher(publisher, mapperFunction, keepOrdered); return new ExecutionResultImpl(mapSourceToResponse, executionContext.getErrors()); }); @@ -75,6 +85,10 @@ public CompletableFuture execute(ExecutionContext executionCont return overallResult; } + private boolean keepOrdered(GraphQLContext graphQLContext) { + return graphQLContext.getOrDefault(KEEP_SUBSCRIPTION_EVENTS_ORDERED, false); + } + /* https://github.com/facebook/graphql/blob/master/spec/Section%206%20--%20Execution.md @@ -99,7 +113,7 @@ private CompletableFuture> createSourceEventStream(ExecutionCo if (publisher != null) { assertTrue(publisher instanceof Publisher, () -> "Your data fetcher must return a Publisher of events when using graphql subscriptions"); } - //noinspection unchecked + //noinspection unchecked,DataFlowIssue return (Publisher) publisher; }); } diff --git a/src/main/java/graphql/execution/reactive/CompletionStageMappingOrderedPublisher.java b/src/main/java/graphql/execution/reactive/CompletionStageMappingOrderedPublisher.java index ad4d6f1cf9..4f4e651ca3 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageMappingOrderedPublisher.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageMappingOrderedPublisher.java @@ -1,14 +1,13 @@ package graphql.execution.reactive; import graphql.Internal; +import org.jetbrains.annotations.NotNull; import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; import java.util.concurrent.CompletionStage; import java.util.function.Function; -import static graphql.Assert.assertNotNullWithNPE; - /** * A reactive Publisher that bridges over another Publisher of `D` and maps the results * to type `U` via a CompletionStage, handling errors in that stage but keeps the results @@ -19,10 +18,7 @@ * @param the upstream type to be mapped to */ @Internal -public class CompletionStageMappingOrderedPublisher implements Publisher { - private final Publisher upstreamPublisher; - private final Function> mapper; - +public class CompletionStageMappingOrderedPublisher extends CompletionStageMappingPublisher { /** * You need the following : * @@ -30,23 +26,11 @@ public class CompletionStageMappingOrderedPublisher implements Publisher upstreamPublisher, Function> mapper) { - this.upstreamPublisher = upstreamPublisher; - this.mapper = mapper; + super(upstreamPublisher, mapper); } @Override - public void subscribe(Subscriber downstreamSubscriber) { - assertNotNullWithNPE(downstreamSubscriber, () -> "Subscriber passed to subscribe must not be null"); - upstreamPublisher.subscribe(new CompletionStageOrderedSubscriber<>(mapper, downstreamSubscriber)); - } - - /** - * Get instance of an upstreamPublisher - * - * @return upstream instance of {@link Publisher} - */ - public Publisher getUpstreamPublisher() { - return upstreamPublisher; + protected @NotNull Subscriber createSubscriber(Subscriber downstreamSubscriber) { + return new CompletionStageOrderedSubscriber<>(mapper, downstreamSubscriber); } - } diff --git a/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java b/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java index 243c8d5258..3e913c62a2 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageMappingPublisher.java @@ -1,12 +1,15 @@ package graphql.execution.reactive; import graphql.Internal; +import org.jetbrains.annotations.NotNull; import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; import java.util.concurrent.CompletionStage; import java.util.function.Function; +import static graphql.Assert.assertNotNullWithNPE; + /** * A reactive Publisher that bridges over another Publisher of `D` and maps the results * to type `U` via a CompletionStage, handling errors in that stage @@ -16,8 +19,8 @@ */ @Internal public class CompletionStageMappingPublisher implements Publisher { - private final Publisher upstreamPublisher; - private final Function> mapper; + protected final Publisher upstreamPublisher; + protected final Function> mapper; /** * You need the following : @@ -32,9 +35,16 @@ public CompletionStageMappingPublisher(Publisher upstreamPublisher, Function< @Override public void subscribe(Subscriber downstreamSubscriber) { - upstreamPublisher.subscribe(new CompletionStageSubscriber<>(mapper, downstreamSubscriber)); + assertNotNullWithNPE(downstreamSubscriber, () -> "Subscriber passed to subscribe must not be null"); + upstreamPublisher.subscribe(createSubscriber(downstreamSubscriber)); + } + + @NotNull + protected Subscriber createSubscriber(Subscriber downstreamSubscriber) { + return new CompletionStageSubscriber<>(mapper, downstreamSubscriber); } + /** * Get instance of an upstreamPublisher * diff --git a/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java b/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java index 975362a6b6..1baca0dbd3 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java @@ -125,6 +125,8 @@ protected void handleThrowableDuringMapping(Throwable throwable) { // has happened, no more messages flow // delegatingSubscription.cancel(); + + cancelInFlightFutures(); } } @@ -133,6 +135,7 @@ public void onError(Throwable t) { // we immediately terminate - we don't wait for any promises to complete if (isTerminal.compareAndSet(false, true)) { downstreamSubscriber.onError(t); + cancelInFlightFutures(); } } @@ -168,6 +171,21 @@ private boolean removeFromInFlightQAndCheckIfEmpty(CompletionStage completion }); } + /** + * If the promise is backed by frameworks such as Reactor, then the cancel() + * can cause them to propagate the cancel back into the reactive chain + */ + private void cancelInFlightFutures() { + lock.runLocked(() -> { + while (!inFlightDataQ.isEmpty()) { + CompletionStage cs = inFlightDataQ.poll(); + if (cs != null) { + cs.toCompletableFuture().cancel(false); + } + } + }); + } + protected boolean inFlightQIsEmpty() { return lock.callLocked(inFlightDataQ::isEmpty); } diff --git a/src/main/java/graphql/execution/reactive/SubscriptionPublisher.java b/src/main/java/graphql/execution/reactive/SubscriptionPublisher.java index ae4176bb2e..7b8b08d19f 100644 --- a/src/main/java/graphql/execution/reactive/SubscriptionPublisher.java +++ b/src/main/java/graphql/execution/reactive/SubscriptionPublisher.java @@ -31,10 +31,15 @@ public class SubscriptionPublisher implements Publisher { * * @param upstreamPublisher the original publisher of objects that then have a graphql selection set applied to them * @param mapper a mapper that turns object into promises to execution results which are then published on this stream + * @param keepOrdered this indicates that the order of results should be kep in the same order as the source events arrive */ @Internal - public SubscriptionPublisher(Publisher upstreamPublisher, Function> mapper) { - mappingPublisher = new CompletionStageMappingPublisher<>(upstreamPublisher, mapper); + public SubscriptionPublisher(Publisher upstreamPublisher, Function> mapper, boolean keepOrdered) { + if (keepOrdered) { + mappingPublisher = new CompletionStageMappingOrderedPublisher<>(upstreamPublisher, mapper); + } else { + mappingPublisher = new CompletionStageMappingPublisher<>(upstreamPublisher, mapper); + } } /** diff --git a/src/test/groovy/graphql/execution/SubscriptionExecutionStrategyTest.groovy b/src/test/groovy/graphql/execution/SubscriptionExecutionStrategyTest.groovy index c6e0f5a52b..3d7bb28086 100644 --- a/src/test/groovy/graphql/execution/SubscriptionExecutionStrategyTest.groovy +++ b/src/test/groovy/graphql/execution/SubscriptionExecutionStrategyTest.groovy @@ -628,4 +628,97 @@ class SubscriptionExecutionStrategyTest extends Specification { instrumentResultCalls.size() == 11 // one for the initial execution and then one for each stream event } + def "emits results in the order they complete"() { + List promises = [] + Publisher publisher = new RxJavaMessagePublisher(10) + + DataFetcher newMessageDF = { env -> return publisher } + DataFetcher senderDF = dfThatDoesNotComplete("sender", promises) + DataFetcher textDF = PropertyDataFetcher.fetching("text") + + GraphQL graphQL = buildSubscriptionQL(newMessageDF, senderDF, textDF) + + def executionInput = ExecutionInput.newExecutionInput().query(""" + subscription NewMessages { + newMessage(roomId: 123) { + sender + text + } + } + """).build() + + def executionResult = graphQL.execute(executionInput) + + when: + Publisher msgStream = executionResult.getData() + def capturingSubscriber = new CapturingSubscriber(100) + msgStream.subscribe(capturingSubscriber) + + // make them all complete but in reverse order + promises.reverse().forEach { it.run() } + + then: + Awaitility.await().untilTrue(capturingSubscriber.isDone()) + + // in order they completed - which was reversed + def messages = capturingSubscriber.events + messages.size() == 10 + for (int i = 0, j = messages.size() - 1; i < messages.size(); i++, j--) { + def message = messages[i].data + assert message == ["newMessage": [sender: "sender" + j, text: "text" + j]] + } + } + + def "emits results in the order they where emitted by source"() { + List promises = [] + Publisher publisher = new RxJavaMessagePublisher(10) + + DataFetcher newMessageDF = { env -> return publisher } + DataFetcher senderDF = dfThatDoesNotComplete("sender", promises) + DataFetcher textDF = PropertyDataFetcher.fetching("text") + + GraphQL graphQL = buildSubscriptionQL(newMessageDF, senderDF, textDF) + + def executionInput = ExecutionInput.newExecutionInput().query(""" + subscription NewMessages { + newMessage(roomId: 123) { + sender + text + } + } + """).graphQLContext([(SubscriptionExecutionStrategy.KEEP_SUBSCRIPTION_EVENTS_ORDERED): true]).build() + + def executionResult = graphQL.execute(executionInput) + + when: + Publisher msgStream = executionResult.getData() + def capturingSubscriber = new CapturingSubscriber(100) + msgStream.subscribe(capturingSubscriber) + + // make them all complete but in reverse order + promises.reverse().forEach { it.run() } + + then: + Awaitility.await().untilTrue(capturingSubscriber.isDone()) + + // in order they were emitted originally - they have been buffered + def messages = capturingSubscriber.events + messages.size() == 10 + for (int i = 0; i < messages.size(); i++) { + def message = messages[i].data + assert message == ["newMessage": [sender: "sender" + i, text: "text" + i]] + } + } + + private static DataFetcher dfThatDoesNotComplete(String propertyName, List promises) { + { env -> + def df = PropertyDataFetcher.fetching(propertyName) + def value = df.get(env) + + def cf = new CompletableFuture() + promises.add({ cf.complete(value) }) + return cf + } + } + } diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy index 2f8a69c260..a8503843dd 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy @@ -8,6 +8,8 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage import java.util.function.Function +import static graphql.execution.reactive.CompletionStageSubscriberTest.mapperThatDoesNotComplete + class CompletionStageOrderedSubscriberTest extends Specification { def "basic test of mapping"() { @@ -140,6 +142,32 @@ class CompletionStageOrderedSubscriberTest extends Specification { capturingSubscriber.events == [] } + def "if onError is called, then futures are cancelled"() { + def capturingSubscriber = new CapturingSubscriber<>() + def subscription = new CapturingSubscription() + List promises = [] + Function> mapper = mapperThatDoesNotComplete([], promises) + def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + + when: + completionStageSubscriber.onSubscribe(subscription) + completionStageSubscriber.onNext(0) + completionStageSubscriber.onNext(1) + completionStageSubscriber.onNext(2) + completionStageSubscriber.onNext(3) + completionStageSubscriber.onError(new RuntimeException("Bang")) + + then: + !capturingSubscriber.isCompleted() + capturingSubscriber.isCompletedExceptionally() + capturingSubscriber.events == [] + + promises.size() == 4 + for (CompletableFuture cf : promises) { + assert cf.isCancelled(), "The CF was not cancelled?" + } + } + def "emits values in the order they arrive not the order they complete"() { def capturingSubscriber = new CapturingSubscriber<>() def subscription = new CapturingSubscription() @@ -166,19 +194,8 @@ class CompletionStageOrderedSubscriberTest extends Specification { then: !subscription.isCancelled() capturingSubscriber.isCompleted() - capturingSubscriber.events == ["0","1","2","3"] + capturingSubscriber.events == ["0", "1", "2", "3"] } - private static Function> mapperThatDoesNotComplete(List promises) { - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - def cf = new CompletableFuture() - promises.add({ cf.complete(String.valueOf(integer)) }) - return cf - } - } - mapper - } } diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy index fe103034fb..847011a79d 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy @@ -135,6 +135,33 @@ class CompletionStageSubscriberTest extends Specification { capturingSubscriber.events == [] } + def "if onError is called, then futures are cancelled"() { + def capturingSubscriber = new CapturingSubscriber<>() + def subscription = new CapturingSubscription() + List promises = [] + Function> mapper = mapperThatDoesNotComplete([], promises) + def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + + when: + completionStageSubscriber.onSubscribe(subscription) + completionStageSubscriber.onNext(0) + completionStageSubscriber.onNext(1) + completionStageSubscriber.onNext(2) + completionStageSubscriber.onNext(3) + completionStageSubscriber.onError(new RuntimeException("Bang")) + + then: + !capturingSubscriber.isCompleted() + capturingSubscriber.isCompletedExceptionally() + capturingSubscriber.events == [] + + promises.size() == 4 + for (CompletableFuture cf : promises) { + assert cf.isCancelled(), "The CF was not cancelled?" + } + } + + def "emits values in the order they complete not how arrive"() { def capturingSubscriber = new CapturingSubscriber<>() def subscription = new CapturingSubscription() @@ -164,12 +191,13 @@ class CompletionStageSubscriberTest extends Specification { capturingSubscriber.events == ["3","2","1","0"] } - private static Function> mapperThatDoesNotComplete(List promises) { + static Function> mapperThatDoesNotComplete(List runToComplete, List promises = []) { def mapper = new Function>() { @Override CompletionStage apply(Integer integer) { def cf = new CompletableFuture() - promises.add({ cf.complete(String.valueOf(integer)) }) + runToComplete.add({ cf.complete(String.valueOf(integer)) }) + promises.add(cf) return cf } } diff --git a/src/test/java/reproductions/SubscriptionReproduction.java b/src/test/java/reproductions/SubscriptionReproduction.java index 59877bffc9..ad7d09f966 100644 --- a/src/test/java/reproductions/SubscriptionReproduction.java +++ b/src/test/java/reproductions/SubscriptionReproduction.java @@ -1,7 +1,9 @@ package reproductions; +import graphql.ExecutionInput; import graphql.ExecutionResult; import graphql.GraphQL; +import graphql.execution.SubscriptionExecutionStrategy; import graphql.schema.DataFetchingEnvironment; import graphql.schema.GraphQLSchema; import graphql.schema.idl.RuntimeWiring; @@ -32,10 +34,12 @@ */ public class SubscriptionReproduction { public static void main(String[] args) { - new SubscriptionReproduction().run(); + new SubscriptionReproduction().run(args); } - private void run() { + private void run(String[] args) { + + boolean ordered = args.length > 0 && "ordered".equals(args[0]); GraphQL graphQL = mkGraphQl(); String query = "subscription MySubscription {\n" + @@ -46,7 +50,11 @@ private void run() { " isFavorite\n" + " }\n" + "}"; - ExecutionResult executionResult = graphQL.execute(query); + + ExecutionInput executionInput = ExecutionInput.newExecutionInput().query(query).graphQLContext( + b -> b.put(SubscriptionExecutionStrategy.KEEP_SUBSCRIPTION_EVENTS_ORDERED, ordered) + ).build(); + ExecutionResult executionResult = graphQL.execute(executionInput); Publisher> publisher = executionResult.getData(); DeathEater eater = new DeathEater(); From f15fbfbe97d071088e29272f7e349bcec680dc50 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Wed, 24 Apr 2024 21:16:14 +1000 Subject: [PATCH 09/67] This has a fix to buffer published events to keep them in order -tweaked method name on review --- .../execution/reactive/CompletionStageOrderedSubscriber.java | 2 +- .../graphql/execution/reactive/CompletionStageSubscriber.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java b/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java index 855dcc6df9..53a8dd4719 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageOrderedSubscriber.java @@ -33,7 +33,7 @@ protected void whenNextFinished(CompletionStage completionStage, D d, Throwab } } finally { boolean empty = inFlightQIsEmpty(); - finallyAfterEachPromisesFinishes(empty); + finallyAfterEachPromiseFinishes(empty); } } diff --git a/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java b/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java index 1baca0dbd3..c9db2ae9e6 100644 --- a/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java +++ b/src/main/java/graphql/execution/reactive/CompletionStageSubscriber.java @@ -96,11 +96,11 @@ protected void whenNextFinished(CompletionStage completionStage, D d, Throwab } } finally { boolean empty = removeFromInFlightQAndCheckIfEmpty(completionStage); - finallyAfterEachPromisesFinishes(empty); + finallyAfterEachPromiseFinishes(empty); } } - protected void finallyAfterEachPromisesFinishes(boolean isInFlightEmpty) { + protected void finallyAfterEachPromiseFinishes(boolean isInFlightEmpty) { // // if the runOnCompleteOrErrorRun runnable is set, the upstream has // called onComplete() already, but the CFs have not all completed From 5943ab02f4cbbb41bd62bc314f18571c76bdd14e Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Thu, 25 Apr 2024 10:23:01 +1000 Subject: [PATCH 10/67] This has a fix to buffer published events to keep them in order - combined the tests so there is less repeated test code --- ...ionStageMappingOrderedPublisherTest.groovy | 280 ------------------ ...CompletionStageMappingPublisherTest.groovy | 61 +++- ...ompletionStageOrderedSubscriberTest.groovy | 195 +----------- .../CompletionStageSubscriberTest.groovy | 33 ++- 4 files changed, 81 insertions(+), 488 deletions(-) delete mode 100644 src/test/groovy/graphql/execution/reactive/CompletionStageMappingOrderedPublisherTest.groovy diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingOrderedPublisherTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingOrderedPublisherTest.groovy deleted file mode 100644 index b8f5cb80af..0000000000 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingOrderedPublisherTest.groovy +++ /dev/null @@ -1,280 +0,0 @@ -package graphql.execution.reactive - -import graphql.TestUtil -import graphql.execution.pubsub.CapturingSubscriber -import io.reactivex.Flowable -import org.awaitility.Awaitility -import org.reactivestreams.Publisher -import spock.lang.Specification - -import java.util.concurrent.CompletableFuture -import java.util.concurrent.CompletionException -import java.util.concurrent.CompletionStage -import java.util.function.Function - -class CompletionStageMappingOrderedPublisherTest extends Specification { - - def "basic mapping"() { - - when: - Publisher rxIntegers = Flowable.range(0, 10) - - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - return CompletableFuture.completedFuture(String.valueOf(integer)) - } - } - Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) - - def capturingSubscriber = new CapturingSubscriber<>() - rxStrings.subscribe(capturingSubscriber) - - then: - - capturingSubscriber.events.size() == 10 - // order is kept - capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] - } - - def "multiple subscribers get their messages"() { - - when: - Publisher rxIntegers = Flowable.range(0, 10) - - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - return CompletableFuture.completedFuture(String.valueOf(integer)) - } - } - Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) - - def capturingSubscriber1 = new CapturingSubscriber<>() - def capturingSubscriber2 = new CapturingSubscriber<>() - rxStrings.subscribe(capturingSubscriber1) - rxStrings.subscribe(capturingSubscriber2) - - then: - - capturingSubscriber1.events.size() == 10 - // order is kept - capturingSubscriber1.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] - - capturingSubscriber2.events.size() == 10 - // order is kept - capturingSubscriber2.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] - } - - def "error handling"() { - when: - Publisher rxIntegers = Flowable.range(0, 10) - - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - - if (integer == 5) { - def future = new CompletableFuture() - future.completeExceptionally(new RuntimeException("Bang")) - return future - } else { - CompletableFuture.completedFuture(String.valueOf(integer)) - } - } - } - Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) - - def capturingSubscriber = new CapturingSubscriber<>() - rxStrings.subscribe(capturingSubscriber) - - then: - - capturingSubscriber.throwable.getMessage() == "Bang" - // - // got this far and cancelled - capturingSubscriber.events.size() == 5 - capturingSubscriber.events == ["0", "1", "2", "3", "4",] - - } - - def "error handling when the error happens in the middle of the processing but before the others complete"() { - when: - Publisher rxIntegers = Flowable.range(0, 10) - - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - - if (integer == 5) { - return CompletableFuture.supplyAsync { - throw new RuntimeException("Bang") - } - } else { - return asyncValueAfterDelay(10, integer) - } - } - } - Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) - - def capturingSubscriber = new CapturingSubscriber<>(10) - rxStrings.subscribe(capturingSubscriber) - - then: - Awaitility.await().untilTrue(capturingSubscriber.isDone()) - - unwrap(capturingSubscriber.throwable).getMessage() == "Bang" - } - - def "error handling when the error happens in the middle of the processing but after the previous ones complete"() { - when: - Publisher rxIntegers = Flowable.range(0, 10) - - List completions = [] - - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - - if (integer < 5) { - def cf = new CompletableFuture() - completions.add({ cf.complete(String.valueOf(integer)) }) - return cf - } else if (integer == 5) { - def cf = new CompletableFuture() - completions.add({ cf.completeExceptionally(new RuntimeException("Bang")) }) - return cf - } else if (integer == 6) { - // complete 5,4,3,2,1 so we have to queue - def reverse = completions.reverse() - for (Runnable r : (reverse)) { - r.run() - } - return CompletableFuture.completedFuture(String.valueOf(integer)) - } else { - return CompletableFuture.completedFuture(String.valueOf(integer)) - } - } - } - Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) - - def capturingSubscriber = new CapturingSubscriber<>(10) - rxStrings.subscribe(capturingSubscriber) - - then: - Awaitility.await().untilTrue(capturingSubscriber.isDone()) - - unwrap(capturingSubscriber.throwable).getMessage() == "Bang" - } - - - def "mapper exception causes onError"() { - when: - Publisher rxIntegers = Flowable.range(0, 10) - - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - - if (integer == 5) { - throw new RuntimeException("Bang") - } else { - CompletableFuture.completedFuture(String.valueOf(integer)) - } - } - } - Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) - - def capturingSubscriber = new CapturingSubscriber<>() - rxStrings.subscribe(capturingSubscriber) - - then: - - capturingSubscriber.throwable.getMessage() == "Bang" - // - // got this far and cancelled - capturingSubscriber.events.size() == 5 - capturingSubscriber.events == ["0", "1", "2", "3", "4",] - } - - - def "asynchronous mapping works with completion"() { - - when: - Publisher rxIntegers = Flowable.range(0, 10) - - Function> mapper = mapperThatDelaysFor(100) - Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) - - def capturingSubscriber = new CapturingSubscriber<>() - rxStrings.subscribe(capturingSubscriber) - - then: - - Awaitility.await().untilTrue(capturingSubscriber.isDone()) - - capturingSubscriber.events.size() == 10 - // order is kept - capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] - } - - def "asynchronous mapping works when they complete out of order"() { - - when: - Publisher rxIntegers = Flowable.range(0, 10) - - Function> mapper = mapperThatRandomlyDelaysFor(5, 15) - Publisher rxStrings = new CompletionStageMappingOrderedPublisher(rxIntegers, mapper) - - // ask for 10 at a time to create some forward pressure - def capturingSubscriber = new CapturingSubscriber<>(10) - rxStrings.subscribe(capturingSubscriber) - - then: - - Awaitility.await().untilTrue(capturingSubscriber.isDone()) - - capturingSubscriber.events.size() == 10 - // the original flow order was kept - capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] - } - - Function> mapperThatDelaysFor(int delay) { - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - return CompletableFuture.supplyAsync({ - Thread.sleep(delay) - println "\t\tcompleted : " + integer - return String.valueOf(integer) - }) - } - } - mapper - } - - Function> mapperThatRandomlyDelaysFor(int delayMin, int delayMax) { - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - return asyncValueAfterDelay(TestUtil.rand(delayMin, delayMax), integer) - } - } - mapper - } - - private static CompletableFuture asyncValueAfterDelay(int delay, int integer) { - return CompletableFuture.supplyAsync({ - Thread.sleep(delay) - println "\t\tcompleted : " + integer - return String.valueOf(integer) - }) - } - - private static Throwable unwrap(Throwable throwable) { - if (throwable instanceof CompletionException) { - return ((CompletionException) throwable).getCause() - } - return throwable - } -} diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy index c2117f9a8d..bc069bcf1e 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageMappingPublisherTest.groovy @@ -10,9 +10,13 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage import java.util.function.Function +/** + * This contains tests for both CompletionStageMappingPublisher and CompletionStageMappingOrderedPublisher because + * they have so much common code + */ class CompletionStageMappingPublisherTest extends Specification { - def "basic mapping"() { + def "basic mapping of #why"() { when: Publisher rxIntegers = Flowable.range(0, 10) @@ -23,7 +27,7 @@ class CompletionStageMappingPublisherTest extends Specification { return CompletableFuture.completedFuture(String.valueOf(integer)) } } - Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) + Publisher rxStrings = creator.call(rxIntegers,mapper) def capturingSubscriber = new CapturingSubscriber<>() rxStrings.subscribe(capturingSubscriber) @@ -31,11 +35,15 @@ class CompletionStageMappingPublisherTest extends Specification { then: capturingSubscriber.events.size() == 10 - capturingSubscriber.events[0] instanceof String - capturingSubscriber.events[0] == "0" + capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + + where: + why | creator + "CompletionStageMappingPublisher" | { Publisher p, Function> m -> new CompletionStageMappingPublisher(p,m) } + "CompletionStageMappingOrderedPublisher" | { Publisher p, Function> m -> new CompletionStageMappingOrderedPublisher(p,m) } } - def "multiple subscribers get there messages"() { + def "multiple subscribers get there messages for #why"() { when: Publisher rxIntegers = Flowable.range(0, 10) @@ -46,7 +54,7 @@ class CompletionStageMappingPublisherTest extends Specification { return CompletableFuture.completedFuture(String.valueOf(integer)) } } - Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) + Publisher rxStrings = creator.call(rxIntegers,mapper) def capturingSubscriber1 = new CapturingSubscriber<>() def capturingSubscriber2 = new CapturingSubscriber<>() @@ -56,10 +64,20 @@ class CompletionStageMappingPublisherTest extends Specification { then: capturingSubscriber1.events.size() == 10 + // order is kept + capturingSubscriber1.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + capturingSubscriber2.events.size() == 10 + // order is kept + capturingSubscriber2.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + + where: + why | creator + "CompletionStageMappingPublisher" | { Publisher p, Function> m -> new CompletionStageMappingPublisher(p,m) } + "CompletionStageMappingOrderedPublisher" | { Publisher p, Function> m -> new CompletionStageMappingOrderedPublisher(p,m) } } - def "error handling"() { + def "error handling for #why"() { when: Publisher rxIntegers = Flowable.range(0, 10) @@ -76,7 +94,7 @@ class CompletionStageMappingPublisherTest extends Specification { } } } - Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) + Publisher rxStrings = creator.call(rxIntegers,mapper) def capturingSubscriber = new CapturingSubscriber<>() rxStrings.subscribe(capturingSubscriber) @@ -87,11 +105,16 @@ class CompletionStageMappingPublisherTest extends Specification { // // got this far and cancelled capturingSubscriber.events.size() == 5 + capturingSubscriber.events == ["0", "1", "2", "3", "4",] + where: + why | creator + "CompletionStageMappingPublisher" | { Publisher p, Function> m -> new CompletionStageMappingPublisher(p,m) } + "CompletionStageMappingOrderedPublisher" | { Publisher p, Function> m -> new CompletionStageMappingOrderedPublisher(p,m) } } - def "mapper exception causes onError"() { + def "mapper exception causes onError for #why"() { when: Publisher rxIntegers = Flowable.range(0, 10) @@ -106,7 +129,7 @@ class CompletionStageMappingPublisherTest extends Specification { } } } - Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) + Publisher rxStrings = creator.call(rxIntegers, mapper) def capturingSubscriber = new CapturingSubscriber<>() rxStrings.subscribe(capturingSubscriber) @@ -117,6 +140,12 @@ class CompletionStageMappingPublisherTest extends Specification { // // got this far and cancelled capturingSubscriber.events.size() == 5 + capturingSubscriber.events == ["0", "1", "2", "3", "4",] + + where: + why | creator + "CompletionStageMappingPublisher" | { Publisher p, Function> m -> new CompletionStageMappingPublisher(p,m) } + "CompletionStageMappingOrderedPublisher" | { Publisher p, Function> m -> new CompletionStageMappingOrderedPublisher(p,m) } } @@ -126,8 +155,8 @@ class CompletionStageMappingPublisherTest extends Specification { when: Publisher rxIntegers = Flowable.range(0, 10) - Function> mapper = mapperThatDelaysFor(100) - Publisher rxStrings = new CompletionStageMappingPublisher(rxIntegers, mapper) + Function> mapper = mapperThatDelaysFor(10) + Publisher rxStrings = creator.call(rxIntegers,mapper) def capturingSubscriber = new CapturingSubscriber<>() rxStrings.subscribe(capturingSubscriber) @@ -137,8 +166,12 @@ class CompletionStageMappingPublisherTest extends Specification { Awaitility.await().untilTrue(capturingSubscriber.isDone()) capturingSubscriber.events.size() == 10 - capturingSubscriber.events[0] instanceof String - capturingSubscriber.events[0] == "0" + capturingSubscriber.events == ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9",] + + where: + why | creator + "CompletionStageMappingPublisher" | { Publisher p, Function> m -> new CompletionStageMappingPublisher(p,m) } + "CompletionStageMappingOrderedPublisher" | { Publisher p, Function> m -> new CompletionStageMappingOrderedPublisher(p,m) } } Function> mapperThatDelaysFor(int delay) { diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy index a8503843dd..3438a87d06 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageOrderedSubscriberTest.groovy @@ -2,200 +2,21 @@ package graphql.execution.reactive import graphql.execution.pubsub.CapturingSubscriber import graphql.execution.pubsub.CapturingSubscription -import spock.lang.Specification +import org.reactivestreams.Subscriber import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage import java.util.function.Function -import static graphql.execution.reactive.CompletionStageSubscriberTest.mapperThatDoesNotComplete +class CompletionStageOrderedSubscriberTest extends CompletionStageSubscriberTest { -class CompletionStageOrderedSubscriberTest extends Specification { - - def "basic test of mapping"() { - def capturingSubscriber = new CapturingSubscriber<>() - def subscription = new CapturingSubscription() - def mapper = new Function>() { - @Override - CompletionStage apply(Integer integer) { - return CompletableFuture.completedFuture(String.valueOf(integer)) - } - } - - when: - def completionStageSubscriber = new CompletionStageOrderedSubscriber(mapper, capturingSubscriber) - - then: - completionStageSubscriber.getDownstreamSubscriber() == capturingSubscriber - - when: - completionStageSubscriber.onSubscribe(subscription) - completionStageSubscriber.onNext(0) - - then: - !subscription.isCancelled() - !capturingSubscriber.isCompleted() - capturingSubscriber.events == ["0"] - - when: - completionStageSubscriber.onNext(1) - - then: - !subscription.isCancelled() - !capturingSubscriber.isCompleted() - capturingSubscriber.events == ["0", "1"] - - when: - completionStageSubscriber.onComplete() - - then: - !subscription.isCancelled() - capturingSubscriber.isCompleted() - capturingSubscriber.events == ["0", "1"] - } - - def "can hold CFs that have not completed and does not emit them even when onComplete is called"() { - def capturingSubscriber = new CapturingSubscriber<>() - def subscription = new CapturingSubscription() - List promises = [] - Function> mapper = mapperThatDoesNotComplete(promises) - def completionStageSubscriber = new CompletionStageOrderedSubscriber(mapper, capturingSubscriber) - - when: - completionStageSubscriber.onSubscribe(subscription) - completionStageSubscriber.onNext(0) - - then: - !subscription.isCancelled() - !capturingSubscriber.isCompleted() - capturingSubscriber.events == [] - - when: - completionStageSubscriber.onNext(1) - - then: - !subscription.isCancelled() - !capturingSubscriber.isCompleted() - capturingSubscriber.events == [] - - when: - completionStageSubscriber.onComplete() - - then: - !subscription.isCancelled() - !capturingSubscriber.isCompleted() - capturingSubscriber.events == [] - - when: - promises.forEach { it.run() } - - then: - !subscription.isCancelled() - capturingSubscriber.isCompleted() - capturingSubscriber.events == ["0", "1"] - } - - def "can hold CFs that have not completed but finishes quickly when onError is called"() { - def capturingSubscriber = new CapturingSubscriber<>() - def subscription = new CapturingSubscription() - List promises = [] - Function> mapper = mapperThatDoesNotComplete(promises) - def completionStageSubscriber = new CompletionStageOrderedSubscriber(mapper, capturingSubscriber) - - when: - completionStageSubscriber.onSubscribe(subscription) - completionStageSubscriber.onNext(0) - - then: - !subscription.isCancelled() - !capturingSubscriber.isCompleted() - capturingSubscriber.events == [] - - when: - completionStageSubscriber.onNext(1) - - then: - !subscription.isCancelled() - !capturingSubscriber.isCompleted() - capturingSubscriber.events == [] - - when: - completionStageSubscriber.onError(new RuntimeException("Bang")) - - then: - !subscription.isCancelled() - !capturingSubscriber.isCompleted() - capturingSubscriber.isCompletedExceptionally() - // it immediately errored out - capturingSubscriber.getThrowable().getMessage() == "Bang" - capturingSubscriber.events == [] - - when: - // even if the promises later complete we are done - promises.forEach { it.run() } - - then: - !subscription.isCancelled() - !capturingSubscriber.isCompleted() - capturingSubscriber.isCompletedExceptionally() - capturingSubscriber.getThrowable().getMessage() == "Bang" - capturingSubscriber.events == [] - } - - def "if onError is called, then futures are cancelled"() { - def capturingSubscriber = new CapturingSubscriber<>() - def subscription = new CapturingSubscription() - List promises = [] - Function> mapper = mapperThatDoesNotComplete([], promises) - def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) - - when: - completionStageSubscriber.onSubscribe(subscription) - completionStageSubscriber.onNext(0) - completionStageSubscriber.onNext(1) - completionStageSubscriber.onNext(2) - completionStageSubscriber.onNext(3) - completionStageSubscriber.onError(new RuntimeException("Bang")) - - then: - !capturingSubscriber.isCompleted() - capturingSubscriber.isCompletedExceptionally() - capturingSubscriber.events == [] - - promises.size() == 4 - for (CompletableFuture cf : promises) { - assert cf.isCancelled(), "The CF was not cancelled?" - } + @Override + protected Subscriber createSubscriber(Function> mapper, CapturingSubscriber capturingSubscriber) { + return new CompletionStageOrderedSubscriber(mapper, capturingSubscriber) } - def "emits values in the order they arrive not the order they complete"() { - def capturingSubscriber = new CapturingSubscriber<>() - def subscription = new CapturingSubscription() - List promises = [] - Function> mapper = mapperThatDoesNotComplete(promises) - def completionStageSubscriber = new CompletionStageOrderedSubscriber(mapper, capturingSubscriber) - - when: - completionStageSubscriber.onSubscribe(subscription) - completionStageSubscriber.onNext(0) - completionStageSubscriber.onNext(1) - completionStageSubscriber.onNext(2) - completionStageSubscriber.onNext(3) - - then: - !subscription.isCancelled() - !capturingSubscriber.isCompleted() - capturingSubscriber.events == [] - - when: - completionStageSubscriber.onComplete() - promises.reverse().forEach { it.run() } - - then: - !subscription.isCancelled() - capturingSubscriber.isCompleted() - capturingSubscriber.events == ["0", "1", "2", "3"] + @Override + protected ArrayList expectedOrderingOfAsyncCompletion() { + return ["0", "1", "2", "3"] } - - } diff --git a/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy b/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy index 847011a79d..45d3d87830 100644 --- a/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy +++ b/src/test/groovy/graphql/execution/reactive/CompletionStageSubscriberTest.groovy @@ -2,14 +2,29 @@ package graphql.execution.reactive import graphql.execution.pubsub.CapturingSubscriber import graphql.execution.pubsub.CapturingSubscription +import org.reactivestreams.Subscriber import spock.lang.Specification import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage import java.util.function.Function +/** + * This can be used to test the CompletionStageSubscriber and CompletionStageOrderedSubscriber + * since they share so much common code. There are protected helpers to create the subscribers + * and set expectations + */ class CompletionStageSubscriberTest extends Specification { + protected Subscriber createSubscriber(Function> mapper, CapturingSubscriber capturingSubscriber) { + def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + completionStageSubscriber + } + + protected ArrayList expectedOrderingOfAsyncCompletion() { + ["3", "2", "1", "0"] + } + def "basic test of mapping"() { def capturingSubscriber = new CapturingSubscriber<>() def subscription = new CapturingSubscription() @@ -19,7 +34,7 @@ class CompletionStageSubscriberTest extends Specification { return CompletableFuture.completedFuture(String.valueOf(integer)) } } - def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + Subscriber completionStageSubscriber = createSubscriber(mapper, capturingSubscriber) when: completionStageSubscriber.onSubscribe(subscription) @@ -52,7 +67,7 @@ class CompletionStageSubscriberTest extends Specification { def subscription = new CapturingSubscription() List promises = [] Function> mapper = mapperThatDoesNotComplete(promises) - def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + Subscriber completionStageSubscriber = createSubscriber(mapper, capturingSubscriber) when: completionStageSubscriber.onSubscribe(subscription) @@ -93,7 +108,8 @@ class CompletionStageSubscriberTest extends Specification { def subscription = new CapturingSubscription() List promises = [] Function> mapper = mapperThatDoesNotComplete(promises) - def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + + Subscriber completionStageSubscriber = createSubscriber(mapper, capturingSubscriber) when: completionStageSubscriber.onSubscribe(subscription) @@ -140,7 +156,8 @@ class CompletionStageSubscriberTest extends Specification { def subscription = new CapturingSubscription() List promises = [] Function> mapper = mapperThatDoesNotComplete([], promises) - def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + + Subscriber completionStageSubscriber = createSubscriber(mapper, capturingSubscriber) when: completionStageSubscriber.onSubscribe(subscription) @@ -162,12 +179,13 @@ class CompletionStageSubscriberTest extends Specification { } - def "emits values in the order they complete not how arrive"() { + def "ordering test - depends on implementation"() { def capturingSubscriber = new CapturingSubscriber<>() def subscription = new CapturingSubscription() List promises = [] Function> mapper = mapperThatDoesNotComplete(promises) - def completionStageSubscriber = new CompletionStageSubscriber(mapper, capturingSubscriber) + + Subscriber completionStageSubscriber = createSubscriber(mapper, capturingSubscriber) when: completionStageSubscriber.onSubscribe(subscription) @@ -188,9 +206,10 @@ class CompletionStageSubscriberTest extends Specification { then: !subscription.isCancelled() capturingSubscriber.isCompleted() - capturingSubscriber.events == ["3","2","1","0"] + capturingSubscriber.events == expectedOrderingOfAsyncCompletion() } + static Function> mapperThatDoesNotComplete(List runToComplete, List promises = []) { def mapper = new Function>() { @Override From 31dcc40ac32f639612195f86c73df27a614c8384 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Fri, 26 Apr 2024 14:01:10 +1000 Subject: [PATCH 11/67] This provides validation on @oneof input types during validation phase Note this is only when the AST literals are present - pure variables are handled later still just like in JS --- .../validation/ArgumentValidationUtil.java | 6 ++ .../graphql/validation/ValidationUtil.java | 19 ++++- src/main/resources/i18n/Validation.properties | 3 +- .../validation/ValidationUtilTest.groovy | 77 +++++++++++++++---- .../rules/ArgumentsOfCorrectTypeTest.groovy | 31 ++++++++ 5 files changed, 115 insertions(+), 21 deletions(-) diff --git a/src/main/java/graphql/validation/ArgumentValidationUtil.java b/src/main/java/graphql/validation/ArgumentValidationUtil.java index 18261d4b0d..a378f40b63 100644 --- a/src/main/java/graphql/validation/ArgumentValidationUtil.java +++ b/src/main/java/graphql/validation/ArgumentValidationUtil.java @@ -91,6 +91,12 @@ protected void handleFieldNotValidError(Value value, GraphQLType type, int in argumentNames.add(0, String.format("[%s]", index)); } + @Override + protected void handleExtraOneOfFieldsError(GraphQLInputObjectType type, Value value) { + errMsgKey = "ArgumentValidationUtil.extraOneOfFieldsError"; + arguments.add(type.getName()); + } + public I18nMsg getMsgAndArgs() { StringBuilder argument = new StringBuilder(argumentName); for (String name : argumentNames) { diff --git a/src/main/java/graphql/validation/ValidationUtil.java b/src/main/java/graphql/validation/ValidationUtil.java index 413b5887fc..1c9ce92b6a 100644 --- a/src/main/java/graphql/validation/ValidationUtil.java +++ b/src/main/java/graphql/validation/ValidationUtil.java @@ -3,6 +3,7 @@ import com.google.common.collect.ImmutableSet; import graphql.Assert; +import graphql.Directives; import graphql.GraphQLContext; import graphql.GraphQLError; import graphql.Internal; @@ -77,6 +78,9 @@ protected void handleFieldNotValidError(ObjectField objectField, GraphQLInputObj protected void handleFieldNotValidError(Value value, GraphQLType type, int index) { } + protected void handleExtraOneOfFieldsError(GraphQLInputObjectType type, Value value) { + } + public boolean isValidLiteralValue(Value value, GraphQLType type, GraphQLSchema schema, GraphQLContext graphQLContext, Locale locale) { if (value == null || value instanceof NullValue) { boolean valid = !(isNonNull(type)); @@ -95,18 +99,18 @@ public boolean isValidLiteralValue(Value value, GraphQLType type, GraphQLSche if (type instanceof GraphQLScalarType) { Optional invalid = parseLiteral(value, ((GraphQLScalarType) type).getCoercing(), graphQLContext, locale); invalid.ifPresent(graphQLError -> handleScalarError(value, (GraphQLScalarType) type, graphQLError)); - return !invalid.isPresent(); + return invalid.isEmpty(); } if (type instanceof GraphQLEnumType) { Optional invalid = parseLiteralEnum(value, (GraphQLEnumType) type, graphQLContext, locale); invalid.ifPresent(graphQLError -> handleEnumError(value, (GraphQLEnumType) type, graphQLError)); - return !invalid.isPresent(); + return invalid.isEmpty(); } if (isList(type)) { return isValidLiteralValue(value, (GraphQLList) type, schema, graphQLContext, locale); } - return type instanceof GraphQLInputObjectType && isValidLiteralValue(value, (GraphQLInputObjectType) type, schema, graphQLContext, locale); + return type instanceof GraphQLInputObjectType && isValidLiteralValueForInputObjectType(value, (GraphQLInputObjectType) type, schema, graphQLContext, locale); } @@ -128,7 +132,7 @@ private Optional parseLiteral(Value value, Coercing coerc } } - boolean isValidLiteralValue(Value value, GraphQLInputObjectType type, GraphQLSchema schema, GraphQLContext graphQLContext, Locale locale) { + boolean isValidLiteralValueForInputObjectType(Value value, GraphQLInputObjectType type, GraphQLSchema schema, GraphQLContext graphQLContext, Locale locale) { if (!(value instanceof ObjectValue)) { handleNotObjectError(value, type); return false; @@ -156,9 +160,16 @@ boolean isValidLiteralValue(Value value, GraphQLInputObjectType type, GraphQL } } + if (type.hasAppliedDirective(Directives.OneOfDirective.getName())) { + if (objectFieldMap.keySet() .size() != 1) { + handleExtraOneOfFieldsError(type,value); + return false; + } + } return true; } + private Set getMissingFields(GraphQLInputObjectType type, Map objectFieldMap, GraphqlFieldVisibility fieldVisibility) { return fieldVisibility.getFieldDefinitions(type).stream() .filter(field -> isNonNull(field.getType())) diff --git a/src/main/resources/i18n/Validation.properties b/src/main/resources/i18n/Validation.properties index c12920da07..d82519b2f9 100644 --- a/src/main/resources/i18n/Validation.properties +++ b/src/main/resources/i18n/Validation.properties @@ -106,4 +106,5 @@ ArgumentValidationUtil.handleNotObjectError=Validation error ({0}) : argument '' ArgumentValidationUtil.handleMissingFieldsError=Validation error ({0}) : argument ''{1}'' with value ''{2}'' is missing required fields ''{3}'' # suppress inspection "UnusedProperty" ArgumentValidationUtil.handleExtraFieldError=Validation error ({0}) : argument ''{1}'' with value ''{2}'' contains a field not in ''{3}'': ''{4}'' - +# suppress inspection "UnusedProperty" +ArgumentValidationUtil.extraOneOfFieldsError=Validation error ({0}) : Exactly one key must be specified for argument ''{1}'' of @oneOf input type ''{3}'' \ No newline at end of file diff --git a/src/test/groovy/graphql/validation/ValidationUtilTest.groovy b/src/test/groovy/graphql/validation/ValidationUtilTest.groovy index 3373809109..251362b09a 100644 --- a/src/test/groovy/graphql/validation/ValidationUtilTest.groovy +++ b/src/test/groovy/graphql/validation/ValidationUtilTest.groovy @@ -19,8 +19,11 @@ import graphql.schema.GraphQLInputObjectType import graphql.schema.GraphQLSchema import spock.lang.Specification +import static graphql.Directives.OneOfDirective import static graphql.Scalars.GraphQLBoolean import static graphql.Scalars.GraphQLString +import static graphql.language.ObjectField.newObjectField +import static graphql.schema.GraphQLAppliedDirective.newDirective import static graphql.schema.GraphQLList.list import static graphql.schema.GraphQLNonNull.nonNull @@ -91,7 +94,7 @@ class ValidationUtilTest extends Specification { def type = list(GraphQLString) expect: - !validationUtil.isValidLiteralValue(arrayValue, type,schema,graphQLContext, locale) + !validationUtil.isValidLiteralValue(arrayValue, type, schema, graphQLContext, locale) } def "One value is a single element List"() { @@ -99,7 +102,7 @@ class ValidationUtilTest extends Specification { def singleValue = new BooleanValue(true) def type = list(GraphQLBoolean) expect: - validationUtil.isValidLiteralValue(singleValue, type,schema,graphQLContext,locale) + validationUtil.isValidLiteralValue(singleValue, type, schema, graphQLContext, locale) } def "a valid array"() { @@ -108,7 +111,7 @@ class ValidationUtilTest extends Specification { def type = list(GraphQLString) expect: - validationUtil.isValidLiteralValue(arrayValue, type,schema, graphQLContext,locale) + validationUtil.isValidLiteralValue(arrayValue, type, schema, graphQLContext, locale) } def "a valid scalar"() { @@ -150,14 +153,14 @@ class ValidationUtilTest extends Specification { def inputObjectType = GraphQLInputObjectType.newInputObject() .name("inputObjectType") .field(GraphQLInputObjectField.newInputObjectField() - .name("hello") - .type(GraphQLString)) + .name("hello") + .type(GraphQLString)) .build() def objectValue = ObjectValue.newObjectValue() objectValue.objectField(new ObjectField("hello", new StringValue("world"))) expect: - validationUtil.isValidLiteralValue(objectValue.build(), inputObjectType, schema, graphQLContext, locale) + validationUtil.isValidLiteralValueForInputObjectType(objectValue.build(), inputObjectType, schema, graphQLContext, locale) } def "a invalid ObjectValue with a invalid field"() { @@ -165,14 +168,14 @@ class ValidationUtilTest extends Specification { def inputObjectType = GraphQLInputObjectType.newInputObject() .name("inputObjectType") .field(GraphQLInputObjectField.newInputObjectField() - .name("hello") - .type(GraphQLString)) + .name("hello") + .type(GraphQLString)) .build() def objectValue = ObjectValue.newObjectValue() objectValue.objectField(new ObjectField("hello", new BooleanValue(false))) expect: - !validationUtil.isValidLiteralValue(objectValue.build(), inputObjectType, schema, graphQLContext,locale) + !validationUtil.isValidLiteralValueForInputObjectType(objectValue.build(), inputObjectType, schema, graphQLContext, locale) } def "a invalid ObjectValue with a missing field"() { @@ -180,13 +183,13 @@ class ValidationUtilTest extends Specification { def inputObjectType = GraphQLInputObjectType.newInputObject() .name("inputObjectType") .field(GraphQLInputObjectField.newInputObjectField() - .name("hello") - .type(nonNull(GraphQLString))) + .name("hello") + .type(nonNull(GraphQLString))) .build() def objectValue = ObjectValue.newObjectValue().build() expect: - !validationUtil.isValidLiteralValue(objectValue, inputObjectType,schema, graphQLContext,locale) + !validationUtil.isValidLiteralValueForInputObjectType(objectValue, inputObjectType, schema, graphQLContext, locale) } def "a valid ObjectValue with a nonNull field and default value"() { @@ -194,13 +197,55 @@ class ValidationUtilTest extends Specification { def inputObjectType = GraphQLInputObjectType.newInputObject() .name("inputObjectType") .field(GraphQLInputObjectField.newInputObjectField() - .name("hello") - .type(nonNull(GraphQLString)) - .defaultValueProgrammatic("default")) + .name("hello") + .type(nonNull(GraphQLString)) + .defaultValueProgrammatic("default")) .build() def objectValue = ObjectValue.newObjectValue() expect: - validationUtil.isValidLiteralValue(objectValue.build(), inputObjectType, schema, graphQLContext,locale) + validationUtil.isValidLiteralValueForInputObjectType(objectValue.build(), inputObjectType, schema, graphQLContext, locale) + } + + def "a valid @oneOf input literal"() { + given: + def inputObjectType = GraphQLInputObjectType.newInputObject() + .name("inputObjectType") + .withAppliedDirective(newDirective().name(OneOfDirective.getName())) + .field(GraphQLInputObjectField.newInputObjectField() + .name("f1") + .type(GraphQLString)) + .field(GraphQLInputObjectField.newInputObjectField() + .name("f2") + .type(GraphQLString)) + .build() + def objectValue = ObjectValue.newObjectValue() + .objectField(newObjectField().name("f1").value(StringValue.of("v1")).build()) + .build() + + expect: + validationUtil.isValidLiteralValueForInputObjectType(objectValue, inputObjectType, schema, graphQLContext, locale) + } + + def "an invalid @oneOf input literal"() { + given: + def inputObjectType = GraphQLInputObjectType.newInputObject() + .name("inputObjectType") + .withAppliedDirective(newDirective().name(OneOfDirective.getName())) + .field(GraphQLInputObjectField.newInputObjectField() + .name("f1") + .type(GraphQLString)) + .field(GraphQLInputObjectField.newInputObjectField() + .name("f2") + .type(GraphQLString)) + .build() + + def objectValue = ObjectValue.newObjectValue() + .objectField(newObjectField().name("f1").value(StringValue.of("v1")).build()) + .objectField(newObjectField().name("f2").value(StringValue.of("v2")).build()) + .build() + + expect: + !validationUtil.isValidLiteralValueForInputObjectType(objectValue, inputObjectType, schema, graphQLContext, locale) } } diff --git a/src/test/groovy/graphql/validation/rules/ArgumentsOfCorrectTypeTest.groovy b/src/test/groovy/graphql/validation/rules/ArgumentsOfCorrectTypeTest.groovy index 67c87407cf..d2b6387e63 100644 --- a/src/test/groovy/graphql/validation/rules/ArgumentsOfCorrectTypeTest.groovy +++ b/src/test/groovy/graphql/validation/rules/ArgumentsOfCorrectTypeTest.groovy @@ -335,6 +335,37 @@ class ArgumentsOfCorrectTypeTest extends Specification { validationErrors.get(0).message == "Validation error (WrongType@[dog/doesKnowCommand]) : argument 'dogCommand' with value 'EnumValue{name='PRETTY'}' is not a valid 'DogCommand' - Literal value not in allowable values for enum 'DogCommand' - 'EnumValue{name='PRETTY'}'" } + def "invalid @oneOf argument - has more than 1 key - case #why"() { + when: + def validationErrors = validate(query) + + then: + validationErrors.size() == 1 + validationErrors.get(0).getValidationErrorType() == ValidationErrorType.WrongType + validationErrors.get(0).message == "Validation error (WrongType@[oneOfField]) : Exactly one key must be specified for argument 'oneOfArg' of @oneOf input type 'oneOfInputType'" + + where: + why | query | _ + 'some variables' | + ''' + query q($v1 : String) { + oneOfField(oneOfArg : { a : $v1, b : "y" }) + } + ''' | _ + 'all variables' | + ''' + query q($v1 : String, $v2 : String) { + oneOfField(oneOfArg : { a : $v1, b : $v2 }) + } + ''' | _ + 'all literals' | + ''' + query q { + oneOfField(oneOfArg : { a : "x", b : "y" }) + } + ''' | _ + } + static List validate(String query) { def document = new Parser().parseDocument(query) return new Validator().validateDocument(SpecValidationSchema.specValidationSchema, document, Locale.ENGLISH) From 64ba67f917b5e12ae27a1e68cf16861378fce089 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Fri, 26 Apr 2024 15:07:38 +1000 Subject: [PATCH 12/67] This provides validation on @oneof input types during validation phase Fixed tests and error messages --- src/main/resources/i18n/Validation.properties | 3 ++- .../groovy/graphql/schema/GraphQLInputObjectTypeTest.groovy | 3 ++- .../graphql/validation/rules/ArgumentsOfCorrectTypeTest.groovy | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/resources/i18n/Validation.properties b/src/main/resources/i18n/Validation.properties index d82519b2f9..4f5c42ab25 100644 --- a/src/main/resources/i18n/Validation.properties +++ b/src/main/resources/i18n/Validation.properties @@ -107,4 +107,5 @@ ArgumentValidationUtil.handleMissingFieldsError=Validation error ({0}) : argumen # suppress inspection "UnusedProperty" ArgumentValidationUtil.handleExtraFieldError=Validation error ({0}) : argument ''{1}'' with value ''{2}'' contains a field not in ''{3}'': ''{4}'' # suppress inspection "UnusedProperty" -ArgumentValidationUtil.extraOneOfFieldsError=Validation error ({0}) : Exactly one key must be specified for argument ''{1}'' of @oneOf input type ''{3}'' \ No newline at end of file +# suppress inspection "UnusedMessageFormatParameter" +ArgumentValidationUtil.extraOneOfFieldsError=Validation error ({0}) : Exactly one key must be specified for OneOf type ''{3}''. \ No newline at end of file diff --git a/src/test/groovy/graphql/schema/GraphQLInputObjectTypeTest.groovy b/src/test/groovy/graphql/schema/GraphQLInputObjectTypeTest.groovy index 09ed18b731..b98fae5391 100644 --- a/src/test/groovy/graphql/schema/GraphQLInputObjectTypeTest.groovy +++ b/src/test/groovy/graphql/schema/GraphQLInputObjectTypeTest.groovy @@ -162,7 +162,8 @@ class GraphQLInputObjectTypeTest extends Specification { er = graphQL.execute('query q { f( arg : {a : "abc", b : 123}) { key value }}') then: !er.errors.isEmpty() - er.errors[0].message == "Exception while fetching data (/f) : Exactly one key must be specified for OneOf type 'OneOf'." + // caught during early validation + er.errors[0].message == "Validation error (WrongType@[f]) : Exactly one key must be specified for OneOf type 'OneOf'." when: def ei = ExecutionInput.newExecutionInput('query q($var : OneOf) { f( arg : $var) { key value }}').variables([var: [a: "abc", b: 123]]).build() diff --git a/src/test/groovy/graphql/validation/rules/ArgumentsOfCorrectTypeTest.groovy b/src/test/groovy/graphql/validation/rules/ArgumentsOfCorrectTypeTest.groovy index d2b6387e63..a3209a9fe3 100644 --- a/src/test/groovy/graphql/validation/rules/ArgumentsOfCorrectTypeTest.groovy +++ b/src/test/groovy/graphql/validation/rules/ArgumentsOfCorrectTypeTest.groovy @@ -342,7 +342,7 @@ class ArgumentsOfCorrectTypeTest extends Specification { then: validationErrors.size() == 1 validationErrors.get(0).getValidationErrorType() == ValidationErrorType.WrongType - validationErrors.get(0).message == "Validation error (WrongType@[oneOfField]) : Exactly one key must be specified for argument 'oneOfArg' of @oneOf input type 'oneOfInputType'" + validationErrors.get(0).message == "Validation error (WrongType@[oneOfField]) : Exactly one key must be specified for OneOf type 'oneOfInputType'." where: why | query | _ From 2c42086f895173ce965dcdb922d36523d10ee01e Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Sun, 28 Apr 2024 17:21:45 +1000 Subject: [PATCH 13/67] This will validate @oneOf variable values when the variables are coerced --- .../execution/ValuesResolverConversion.java | 32 +- .../execution/ValuesResolverTest.groovy | 294 +++++++++++------- 2 files changed, 209 insertions(+), 117 deletions(-) diff --git a/src/main/java/graphql/execution/ValuesResolverConversion.java b/src/main/java/graphql/execution/ValuesResolverConversion.java index eff4d1cef1..66b84bb198 100644 --- a/src/main/java/graphql/execution/ValuesResolverConversion.java +++ b/src/main/java/graphql/execution/ValuesResolverConversion.java @@ -374,6 +374,7 @@ static CoercedVariables externalValueToInternalValueForVariables( coercedValues.put(variableName, null); } else { Object coercedValue = externalValueToInternalValueImpl( + variableName, inputInterceptor, fieldVisibility, variableInputType, @@ -398,11 +399,28 @@ static CoercedVariables externalValueToInternalValueForVariables( return CoercedVariables.of(coercedValues); } + static Object externalValueToInternalValueImpl( + InputInterceptor inputInterceptor, + GraphqlFieldVisibility fieldVisibility, + GraphQLInputType graphQLType, + Object originalValue, + GraphQLContext graphqlContext, + Locale locale + ) throws NonNullableValueCoercedAsNullException, CoercingParseValueException { + return externalValueToInternalValueImpl("externalValue", + inputInterceptor, + fieldVisibility, + graphQLType, + originalValue, + graphqlContext, + locale); + } + /** * Performs validation too */ - @SuppressWarnings("unchecked") static Object externalValueToInternalValueImpl( + String variableName, InputInterceptor inputInterceptor, GraphqlFieldVisibility fieldVisibility, GraphQLInputType graphQLType, @@ -412,6 +430,7 @@ static Object externalValueToInternalValueImpl( ) throws NonNullableValueCoercedAsNullException, CoercingParseValueException { if (isNonNull(graphQLType)) { Object returnValue = externalValueToInternalValueImpl( + variableName, inputInterceptor, fieldVisibility, unwrapOneAs(graphQLType), @@ -458,13 +477,18 @@ static Object externalValueToInternalValueImpl( locale); } else if (graphQLType instanceof GraphQLInputObjectType) { if (value instanceof Map) { - return externalValueToInternalValueForObject( + GraphQLInputObjectType inputObjectType = (GraphQLInputObjectType) graphQLType; + //noinspection unchecked + Map coercedMap = externalValueToInternalValueForObject( inputInterceptor, fieldVisibility, - (GraphQLInputObjectType) graphQLType, + inputObjectType, (Map) value, graphqlContext, locale); + + ValuesResolverOneOfValidation.validateOneOfInputTypes(inputObjectType, coercedMap, null, variableName, locale); + return coercedMap; } else { throw CoercingParseValueException.newCoercingParseValueException() .message("Expected type 'Map' but was '" + value.getClass().getSimpleName() + @@ -479,7 +503,7 @@ static Object externalValueToInternalValueImpl( /** * performs validation */ - private static Object externalValueToInternalValueForObject( + private static Map externalValueToInternalValueForObject( InputInterceptor inputInterceptor, GraphqlFieldVisibility fieldVisibility, GraphQLInputObjectType inputObjectType, diff --git a/src/test/groovy/graphql/execution/ValuesResolverTest.groovy b/src/test/groovy/graphql/execution/ValuesResolverTest.groovy index 1a7aa22b3e..97af997047 100644 --- a/src/test/groovy/graphql/execution/ValuesResolverTest.groovy +++ b/src/test/groovy/graphql/execution/ValuesResolverTest.groovy @@ -23,9 +23,11 @@ import graphql.language.Value import graphql.language.VariableDefinition import graphql.language.VariableReference import graphql.schema.CoercingParseValueException +import graphql.schema.DataFetcher import spock.lang.Specification import spock.lang.Unroll +import static graphql.ExecutionInput.newExecutionInput import static graphql.Scalars.GraphQLBoolean import static graphql.Scalars.GraphQLFloat import static graphql.Scalars.GraphQLInt @@ -88,6 +90,72 @@ class ValuesResolverTest extends Specification { [name: 'x'] || [name: 'x'] } + def "getVariableValues: @oneOf map object as variable input"() { + given: + def aField = newInputObjectField() + .name("a") + .type(GraphQLString) + def bField = newInputObjectField() + .name("b") + .type(GraphQLString) + def inputType = newInputObject() + .name("Person") + .withAppliedDirective(Directives.OneOfDirective.toAppliedDirective()) + .field(aField) + .field(bField) + .build() + def schema = TestUtil.schemaWithInputType(inputType) + VariableDefinition variableDefinition = new VariableDefinition("variable", new TypeName("Person")) + + when: + def resolvedValues = ValuesResolver.coerceVariableValues(schema, [variableDefinition], RawVariables.of([variable: [a: 'x']]), graphQLContext, locale) + then: + resolvedValues.get('variable') == [a: 'x'] + + when: + resolvedValues = ValuesResolver.coerceVariableValues(schema, [variableDefinition], RawVariables.of([variable: [b: 'y']]), graphQLContext, locale) + then: + resolvedValues.get('variable') == [b: 'y'] + + when: + ValuesResolver.coerceVariableValues(schema, [variableDefinition], RawVariables.of([variable: [a: 'x', b: 'y']]), graphQLContext, locale) + then: + thrown(OneOfTooManyKeysException.class) + } + + def "can validate inner input oneOf fields"() { + // + // a test from https://github.com/graphql-java/graphql-java/issues/3572 + // + def sdl = ''' + input OneOf @oneOf { a: Int, b: Int } + type Outer { inner(oneof: OneOf!): Boolean } + type Query { outer: Outer } + ''' + + DataFetcher outer = { env -> return null } + def graphQL = TestUtil.graphQL(sdl, [Query: [outer: outer]]).build() + + def query = ''' + query ($oneof: OneOf!) { + outer { + # these variables are never accessed by a data fetcher because + # Query.outer always returns null + inner(oneof: $oneof) + } + } + ''' + + when: + def er = graphQL.execute( + newExecutionInput(query).variables([oneof: [a: 2, b: 1]]) + ) + + then: + er.errors.size() == 1 + er.errors[0].message == "Exactly one key must be specified for OneOf type 'OneOf'." + } + class Person { def name = "" @@ -460,59 +528,59 @@ class ValuesResolverTest extends Specification { e.message == "Exactly one key must be specified for OneOf type 'OneOfInputObject'." where: - testCase | inputValue | variables + testCase | inputValue | variables '{oneOfField: {a: "abc", b: 123} } {}' | buildObjectLiteral([ oneOfField: [ a: StringValue.of("abc"), b: IntValue.of(123) ] - ]) | CoercedVariables.emptyVariables() + ]) | CoercedVariables.emptyVariables() '{oneOfField: {a: null, b: 123 }} {}' | buildObjectLiteral([ oneOfField: [ a: NullValue.of(), b: IntValue.of(123) ] - ]) | CoercedVariables.emptyVariables() + ]) | CoercedVariables.emptyVariables() '{oneOfField: {a: $var, b: 123 }} { var: null }' | buildObjectLiteral([ oneOfField: [ a: VariableReference.of("var"), b: IntValue.of(123) ] - ]) | CoercedVariables.of(["var": null]) + ]) | CoercedVariables.of(["var": null]) '{oneOfField: {a: $var, b: 123 }} {}' | buildObjectLiteral([ oneOfField: [ a: VariableReference.of("var"), b: IntValue.of(123) ] - ]) | CoercedVariables.emptyVariables() + ]) | CoercedVariables.emptyVariables() '{oneOfField: {a : "abc", b : null}} {}' | buildObjectLiteral([ oneOfField: [ a: StringValue.of("abc"), b: NullValue.of() ] - ]) | CoercedVariables.emptyVariables() + ]) | CoercedVariables.emptyVariables() '{oneOfField: {a : null, b : null}} {}' | buildObjectLiteral([ oneOfField: [ a: NullValue.of(), b: NullValue.of() ] - ]) | CoercedVariables.emptyVariables() + ]) | CoercedVariables.emptyVariables() '{oneOfField: {a : $a, b : $b}} {a : "abc"}' | buildObjectLiteral([ oneOfField: [ a: VariableReference.of("a"), b: VariableReference.of("v") ] - ]) | CoercedVariables.of(["a": "abc"]) + ]) | CoercedVariables.of(["a": "abc"]) '$var {var : {oneOfField: { a : "abc", b : 123}}}' | VariableReference.of("var") - | CoercedVariables.of(["var": ["oneOfField": ["a": "abc", "b": 123]]]) + | CoercedVariables.of(["var": ["oneOfField": ["a": "abc", "b": 123]]]) - '$var {var : {oneOfField: {} }}' | VariableReference.of("var") - | CoercedVariables.of(["var": ["oneOfField": [:]]]) + '$var {var : {oneOfField: {} }}' | VariableReference.of("var") + | CoercedVariables.of(["var": ["oneOfField": [:]]]) } @@ -600,7 +668,7 @@ class ValuesResolverTest extends Specification { a: VariableReference.of("var") ]) | CoercedVariables.of(["var": null]) - '`{ a: $var }` { }' | buildObjectLiteral([ + '`{ a: $var }` { }' | buildObjectLiteral([ a: VariableReference.of("var") ]) | CoercedVariables.emptyVariables() } @@ -631,38 +699,38 @@ class ValuesResolverTest extends Specification { where: - testCase | inputArray | variables + testCase | inputArray | variables '[{ a: "abc", b: 123 }]' - | ArrayValue.newArrayValue() - .value(buildObjectLiteral([ - a: StringValue.of("abc"), - b: IntValue.of(123) - ])).build() - | CoercedVariables.emptyVariables() + | ArrayValue.newArrayValue() + .value(buildObjectLiteral([ + a: StringValue.of("abc"), + b: IntValue.of(123) + ])).build() + | CoercedVariables.emptyVariables() '[{ a: "abc" }, { a: "xyz", b: 789 }]' - | ArrayValue.newArrayValue() - .values([ - buildObjectLiteral([ + | ArrayValue.newArrayValue() + .values([ + buildObjectLiteral([ a: StringValue.of("abc") - ]), - buildObjectLiteral([ + ]), + buildObjectLiteral([ a: StringValue.of("xyz"), b: IntValue.of(789) - ]), - ]).build() - | CoercedVariables.emptyVariables() + ]), + ]).build() + | CoercedVariables.emptyVariables() '[{ a: "abc" }, $var ] [{ a: "abc" }, { a: "xyz", b: 789 }]' - | ArrayValue.newArrayValue() - .values([ - buildObjectLiteral([ + | ArrayValue.newArrayValue() + .values([ + buildObjectLiteral([ a: StringValue.of("abc") - ]), - VariableReference.of("var") - ]).build() - | CoercedVariables.of("var": [a: "xyz", b: 789]) + ]), + VariableReference.of("var") + ]).build() + | CoercedVariables.of("var": [a: "xyz", b: 789]) } @@ -692,31 +760,31 @@ class ValuesResolverTest extends Specification { where: - testCase | inputArray | variables + testCase | inputArray | variables '[{ a: "abc" }, { a: null }]' - | ArrayValue.newArrayValue() - .values([ - buildObjectLiteral([ + | ArrayValue.newArrayValue() + .values([ + buildObjectLiteral([ a: StringValue.of("abc") - ]), - buildObjectLiteral([ + ]), + buildObjectLiteral([ a: NullValue.of() - ]), - ]).build() - | CoercedVariables.emptyVariables() + ]), + ]).build() + | CoercedVariables.emptyVariables() '[{ a: "abc" }, { a: $var }] [{ a: "abc" }, { a: null }]' - | ArrayValue.newArrayValue() - .values([ - buildObjectLiteral([ + | ArrayValue.newArrayValue() + .values([ + buildObjectLiteral([ a: StringValue.of("abc") - ]), - buildObjectLiteral([ + ]), + buildObjectLiteral([ a: VariableReference.of("var") - ]), - ]).build() - | CoercedVariables.of("var": null) + ]), + ]).build() + | CoercedVariables.of("var": null) } @@ -746,38 +814,38 @@ class ValuesResolverTest extends Specification { where: - testCase | inputArray | variables + testCase | inputArray | variables '[{ a: "abc", b: 123 }]' - | ArrayValue.newArrayValue() - .value(buildObjectLiteral([ - a: StringValue.of("abc"), - b: IntValue.of(123) - ])).build() - | CoercedVariables.emptyVariables() + | ArrayValue.newArrayValue() + .value(buildObjectLiteral([ + a: StringValue.of("abc"), + b: IntValue.of(123) + ])).build() + | CoercedVariables.emptyVariables() '[{ a: "abc" }, { a: "xyz", b: 789 }]' - | ArrayValue.newArrayValue() - .values([ - buildObjectLiteral([ + | ArrayValue.newArrayValue() + .values([ + buildObjectLiteral([ a: StringValue.of("abc") - ]), - buildObjectLiteral([ + ]), + buildObjectLiteral([ a: StringValue.of("xyz"), b: IntValue.of(789) - ]), - ]).build() - | CoercedVariables.emptyVariables() + ]), + ]).build() + | CoercedVariables.emptyVariables() '[{ a: "abc" }, $var ] [{ a: "abc" }, { a: "xyz", b: 789 }]' - | ArrayValue.newArrayValue() - .values([ - buildObjectLiteral([ + | ArrayValue.newArrayValue() + .values([ + buildObjectLiteral([ a: StringValue.of("abc") - ]), - VariableReference.of("var") - ]).build() - | CoercedVariables.of("var": [a: "xyz", b: 789]) + ]), + VariableReference.of("var") + ]).build() + | CoercedVariables.of("var": [a: "xyz", b: 789]) } @@ -807,38 +875,38 @@ class ValuesResolverTest extends Specification { where: - testCase | inputArray | variables + testCase | inputArray | variables '[{ a: "abc", b: 123 }]' - | ArrayValue.newArrayValue() - .value(buildObjectLiteral([ - a: StringValue.of("abc"), - b: IntValue.of(123) - ])).build() - | CoercedVariables.emptyVariables() + | ArrayValue.newArrayValue() + .value(buildObjectLiteral([ + a: StringValue.of("abc"), + b: IntValue.of(123) + ])).build() + | CoercedVariables.emptyVariables() '[{ a: "abc" }, { a: "xyz", b: 789 }]' - | ArrayValue.newArrayValue() - .values([ - buildObjectLiteral([ + | ArrayValue.newArrayValue() + .values([ + buildObjectLiteral([ a: StringValue.of("abc") - ]), - buildObjectLiteral([ + ]), + buildObjectLiteral([ a: StringValue.of("xyz"), b: IntValue.of(789) - ]), - ]).build() - | CoercedVariables.emptyVariables() + ]), + ]).build() + | CoercedVariables.emptyVariables() '[{ a: "abc" }, $var ] [{ a: "abc" }, { a: "xyz", b: 789 }]' - | ArrayValue.newArrayValue() - .values([ - buildObjectLiteral([ + | ArrayValue.newArrayValue() + .values([ + buildObjectLiteral([ a: StringValue.of("abc") - ]), - VariableReference.of("var") - ]).build() - | CoercedVariables.of("var": [a: "xyz", b: 789]) + ]), + VariableReference.of("var") + ]).build() + | CoercedVariables.of("var": [a: "xyz", b: 789]) } @@ -915,26 +983,26 @@ class ValuesResolverTest extends Specification { where: - testCase | inputArray | variables | expectedValues + testCase | inputArray | variables | expectedValues '[{ a: "abc"}]' - | ArrayValue.newArrayValue() - .value(buildObjectLiteral([ - a: StringValue.of("abc"), - ])).build() - | CoercedVariables.emptyVariables() - | [arg: [[a: "abc"]]] + | ArrayValue.newArrayValue() + .value(buildObjectLiteral([ + a: StringValue.of("abc"), + ])).build() + | CoercedVariables.emptyVariables() + | [arg: [[a: "abc"]]] '[{ a: "abc" }, $var ] [{ a: "abc" }, { b: 789 }]' - | ArrayValue.newArrayValue() - .values([ - buildObjectLiteral([ + | ArrayValue.newArrayValue() + .values([ + buildObjectLiteral([ a: StringValue.of("abc") - ]), - VariableReference.of("var") - ]).build() - | CoercedVariables.of("var": [b: 789]) - | [arg: [[a: "abc"], [b: 789]]] + ]), + VariableReference.of("var") + ]).build() + | CoercedVariables.of("var": [b: 789]) + | [arg: [[a: "abc"], [b: 789]]] } @@ -1223,7 +1291,7 @@ class ValuesResolverTest extends Specification { } ''' - def executionInput = ExecutionInput.newExecutionInput() + def executionInput = newExecutionInput() .query(mutation) .variables([input: [name: 'Name', position: 'UNKNOWN_POSITION']]) .build() @@ -1261,7 +1329,7 @@ class ValuesResolverTest extends Specification { } ''' - def executionInput = ExecutionInput.newExecutionInput() + def executionInput = newExecutionInput() .query(mutation) .variables([input: [name: 'Name', hilarious: 'sometimes']]) .build() @@ -1299,7 +1367,7 @@ class ValuesResolverTest extends Specification { } ''' - def executionInput = ExecutionInput.newExecutionInput() + def executionInput = newExecutionInput() .query(mutation) .variables([input: [name: 'Name', laughsPerMinute: 'none']]) .build() From 6044ec401544d64205c5d45c894faa4a1d552c08 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Sun, 28 Apr 2024 17:43:30 +1000 Subject: [PATCH 14/67] This will validate @oneOf variable values when the variables are coerced - fixed tests --- .../groovy/graphql/schema/GraphQLInputObjectTypeTest.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/groovy/graphql/schema/GraphQLInputObjectTypeTest.groovy b/src/test/groovy/graphql/schema/GraphQLInputObjectTypeTest.groovy index 09ed18b731..94b0dd1782 100644 --- a/src/test/groovy/graphql/schema/GraphQLInputObjectTypeTest.groovy +++ b/src/test/groovy/graphql/schema/GraphQLInputObjectTypeTest.groovy @@ -169,14 +169,14 @@ class GraphQLInputObjectTypeTest extends Specification { er = graphQL.execute(ei) then: !er.errors.isEmpty() - er.errors[0].message == "Exception while fetching data (/f) : Exactly one key must be specified for OneOf type 'OneOf'." + er.errors[0].message == "Exactly one key must be specified for OneOf type 'OneOf'." when: ei = ExecutionInput.newExecutionInput('query q($var : OneOf) { f( arg : $var) { key value }}').variables([var: [a: null]]).build() er = graphQL.execute(ei) then: !er.errors.isEmpty() - er.errors[0].message == "Exception while fetching data (/f) : OneOf type field 'OneOf.a' must be non-null." + er.errors[0].message == "OneOf type field 'OneOf.a' must be non-null." // lots more covered in unit tests } From 65c5afc19eb21ee40467d25d2503d9424a9f9d0e Mon Sep 17 00:00:00 2001 From: Juliano Prado Date: Mon, 13 May 2024 10:56:30 +1000 Subject: [PATCH 15/67] Adding missed properties on IncrementalExecutionResultImpl.Builder.from --- .../graphql/incremental/IncrementalExecutionResultImpl.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java b/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java index 765453260d..c7456d0e53 100644 --- a/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java +++ b/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java @@ -91,6 +91,8 @@ public Builder incrementalItemPublisher(Publisher Date: Thu, 16 May 2024 09:25:20 +1000 Subject: [PATCH 16/67] Adding test to method FROM --- .../IncrementalExecutionResultTest.groovy | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy b/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy index 25eb197ec5..6770a6b8ce 100644 --- a/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy +++ b/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy @@ -1,6 +1,9 @@ package graphql.incremental import graphql.execution.ResultPath +import groovy.json.JsonOutput +import io.reactivex.Flowable +import org.reactivestreams.Publisher import spock.lang.Specification import static graphql.incremental.DeferPayload.newDeferredItem @@ -80,6 +83,41 @@ class IncrementalExecutionResultTest extends Specification { extensions: [some: "map"], hasNext : false, ] + } + + def "sanity test to check Builder.from works"() { + + when: + def defer1 = newDeferredItem() + .label("homeWorldDefer") + .path(ResultPath.parse("/person")) + .data([homeWorld: "Tatooine"]) + .build() + + + def incrementalExecutionResult = new IncrementalExecutionResultImpl.Builder() + .data([ + person: [ + name : "Luke Skywalker", + films: [ + [title: "A New Hope"] + ] + ] + ]) + .incrementalItemPublisher { Flowable.range(1,10)} + .hasNext(true) + .incremental([defer1]) + .extensions([some: "map"]) + .build() + + def newIncrementalExecutionResult = new IncrementalExecutionResultImpl.Builder().from(incrementalExecutionResult).build() + then: + newIncrementalExecutionResult.incremental == incrementalExecutionResult.incremental + newIncrementalExecutionResult.extensions == incrementalExecutionResult.extensions + newIncrementalExecutionResult.errors == incrementalExecutionResult.errors + newIncrementalExecutionResult.incrementalItemPublisher == incrementalExecutionResult.incrementalItemPublisher + newIncrementalExecutionResult.hasNext() == incrementalExecutionResult.hasNext() + newIncrementalExecutionResult.toSpecification() == newIncrementalExecutionResult.toSpecification() } } From 8bb49631230bcbbe362e12c47d1ebd204802bbcf Mon Sep 17 00:00:00 2001 From: James Bellenger Date: Fri, 17 May 2024 04:48:30 -0700 Subject: [PATCH 17/67] init --- .../idl/ArgValueOfAllowedTypeChecker.java | 27 +++++++++---------- .../DirectiveIllegalArgumentTypeError.java | 1 - .../schema/idl/SchemaTypeCheckerTest.groovy | 4 +-- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/main/java/graphql/schema/idl/ArgValueOfAllowedTypeChecker.java b/src/main/java/graphql/schema/idl/ArgValueOfAllowedTypeChecker.java index 9dcd16b768..e00a40812f 100644 --- a/src/main/java/graphql/schema/idl/ArgValueOfAllowedTypeChecker.java +++ b/src/main/java/graphql/schema/idl/ArgValueOfAllowedTypeChecker.java @@ -31,6 +31,7 @@ import graphql.schema.GraphQLScalarType; import graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError; +import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; @@ -41,7 +42,6 @@ import static graphql.collect.ImmutableKit.emptyList; import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.DUPLICATED_KEYS_MESSAGE; import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.EXPECTED_ENUM_MESSAGE; -import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.EXPECTED_LIST_MESSAGE; import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.EXPECTED_NON_NULL_MESSAGE; import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.EXPECTED_OBJECT_MESSAGE; import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.MISSING_REQUIRED_FIELD_MESSAGE; @@ -259,25 +259,24 @@ private void checkArgValueMatchesAllowedNonNullType(List errors, V } private void checkArgValueMatchesAllowedListType(List errors, Value instanceValue, ListType allowedArgType) { - if (instanceValue instanceof NullValue) { - return; + // From the spec, on input coercion: + // If the value passed as an input to a list type is not a list and not the null value, + // then the result of input coercion is a list of size one where the single item value + // is the result of input coercion for the list’s item type on the provided value + // (note this may apply recursively for nested lists). + + Value coercedInstanceValue = instanceValue; + if (!(instanceValue instanceof ArrayValue) && !(instanceValue instanceof NullValue)) { + coercedInstanceValue = new ArrayValue(Collections.singletonList(instanceValue)); } - Type unwrappedAllowedType = allowedArgType.getType(); - if (!(instanceValue instanceof ArrayValue)) { - checkArgValueMatchesAllowedType(errors, instanceValue, unwrappedAllowedType); + if (coercedInstanceValue instanceof NullValue) { return; } - ArrayValue arrayValue = ((ArrayValue) instanceValue); - boolean isUnwrappedList = unwrappedAllowedType instanceof ListType; - - // validate each instance value in the list, all instances must match for the list to match + Type unwrappedAllowedType = allowedArgType.getType(); + ArrayValue arrayValue = ((ArrayValue) coercedInstanceValue); arrayValue.getValues().forEach(value -> { - // restrictive check for sub-arrays - if (isUnwrappedList && !(value instanceof ArrayValue)) { - addValidationError(errors, EXPECTED_LIST_MESSAGE, value.getClass().getSimpleName()); - } checkArgValueMatchesAllowedType(errors, value, unwrappedAllowedType); }); } diff --git a/src/main/java/graphql/schema/idl/errors/DirectiveIllegalArgumentTypeError.java b/src/main/java/graphql/schema/idl/errors/DirectiveIllegalArgumentTypeError.java index 3097df4ccb..70ef1930e2 100644 --- a/src/main/java/graphql/schema/idl/errors/DirectiveIllegalArgumentTypeError.java +++ b/src/main/java/graphql/schema/idl/errors/DirectiveIllegalArgumentTypeError.java @@ -16,7 +16,6 @@ public class DirectiveIllegalArgumentTypeError extends BaseError { public static final String NOT_A_VALID_SCALAR_LITERAL_MESSAGE = "Argument value is not a valid value of scalar '%s'."; public static final String MISSING_REQUIRED_FIELD_MESSAGE = "Missing required field '%s'."; public static final String EXPECTED_NON_NULL_MESSAGE = "Argument value is 'null', expected a non-null value."; - public static final String EXPECTED_LIST_MESSAGE = "Argument value is '%s', expected a list value."; public static final String EXPECTED_OBJECT_MESSAGE = "Argument value is of type '%s', expected an Object value."; public DirectiveIllegalArgumentTypeError(Node element, String elementName, String directiveName, String argumentName, String detailedMessaged) { diff --git a/src/test/groovy/graphql/schema/idl/SchemaTypeCheckerTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaTypeCheckerTest.groovy index 59e027073b..32ce158baf 100644 --- a/src/test/groovy/graphql/schema/idl/SchemaTypeCheckerTest.groovy +++ b/src/test/groovy/graphql/schema/idl/SchemaTypeCheckerTest.groovy @@ -21,7 +21,6 @@ import spock.lang.Unroll import static graphql.schema.GraphQLScalarType.newScalar import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.DUPLICATED_KEYS_MESSAGE import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.EXPECTED_ENUM_MESSAGE -import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.EXPECTED_LIST_MESSAGE import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.EXPECTED_NON_NULL_MESSAGE import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.EXPECTED_OBJECT_MESSAGE import static graphql.schema.idl.errors.DirectiveIllegalArgumentTypeError.MISSING_REQUIRED_FIELD_MESSAGE @@ -1514,11 +1513,11 @@ class SchemaTypeCheckerTest extends Specification { "ACustomDate" | '"AFailingDate"' | format(NOT_A_VALID_SCALAR_LITERAL_MESSAGE, "ACustomDate") "[String!]" | '["str", null]' | format(EXPECTED_NON_NULL_MESSAGE) "[[String!]!]" | '[["str"], ["str2", null]]' | format(EXPECTED_NON_NULL_MESSAGE) + "[[String!]!]" | '[["str"], ["str2", "str3"], null]' | format(EXPECTED_NON_NULL_MESSAGE) "WEEKDAY" | '"somestr"' | format(EXPECTED_ENUM_MESSAGE, "StringValue") "WEEKDAY" | 'SATURDAY' | format(MUST_BE_VALID_ENUM_VALUE_MESSAGE, "SATURDAY", "MONDAY,TUESDAY") "UserInput" | '{ fieldNonNull: "str", fieldNonNull: "dupeKey" }' | format(DUPLICATED_KEYS_MESSAGE, "fieldNonNull") "UserInput" | '{ fieldNonNull: "str", unknown: "field" }' | format(UNKNOWN_FIELDS_MESSAGE, "unknown", "UserInput") - "UserInput" | '{ fieldNonNull: "str", fieldArrayOfArray: ["ArrayInsteadOfArrayOfArray"] }' | format(EXPECTED_LIST_MESSAGE, "StringValue") "UserInput" | '{ fieldNonNull: "str", fieldNestedInput: "strInsteadOfObject" }' | format(EXPECTED_OBJECT_MESSAGE, "StringValue") "UserInput" | '{ field: "missing the `fieldNonNull` entry"}' | format(MISSING_REQUIRED_FIELD_MESSAGE, "fieldNonNull") } @@ -1570,6 +1569,7 @@ class SchemaTypeCheckerTest extends Specification { "[String]" | 'null' "[String!]!" | '["str"]' "[[String!]!]" | '[["str"], ["str2", "str3"]]' + "[[String!]]" | '[["str"], ["str2", "str3"], null]' "WEEKDAY" | 'MONDAY' "UserInput" | '{ fieldNonNull: "str" }' "UserInput" | '{ fieldNonNull: "str", fieldString: "Hey" }' From 259292bac7089145ffe13229179a17ab469304ab Mon Sep 17 00:00:00 2001 From: James Bellenger Date: Fri, 17 May 2024 04:55:48 -0700 Subject: [PATCH 18/67] more tests --- .../groovy/graphql/schema/idl/SchemaTypeCheckerTest.groovy | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/groovy/graphql/schema/idl/SchemaTypeCheckerTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaTypeCheckerTest.groovy index 32ce158baf..b558c97c85 100644 --- a/src/test/groovy/graphql/schema/idl/SchemaTypeCheckerTest.groovy +++ b/src/test/groovy/graphql/schema/idl/SchemaTypeCheckerTest.groovy @@ -1511,6 +1511,7 @@ class SchemaTypeCheckerTest extends Specification { allowedArgType | argValue | detailedMessage "ACustomDate" | '"AFailingDate"' | format(NOT_A_VALID_SCALAR_LITERAL_MESSAGE, "ACustomDate") + "[String]" | 123 | format(NOT_A_VALID_SCALAR_LITERAL_MESSAGE, "String") "[String!]" | '["str", null]' | format(EXPECTED_NON_NULL_MESSAGE) "[[String!]!]" | '[["str"], ["str2", null]]' | format(EXPECTED_NON_NULL_MESSAGE) "[[String!]!]" | '[["str"], ["str2", "str3"], null]' | format(EXPECTED_NON_NULL_MESSAGE) @@ -1567,8 +1568,10 @@ class SchemaTypeCheckerTest extends Specification { "ACustomDate" | '2002' "[String]" | '["str", null]' "[String]" | 'null' + "[String]" | '"str"' // see #2001 "[String!]!" | '["str"]' "[[String!]!]" | '[["str"], ["str2", "str3"]]' + "[[String]]" | '[["str"], ["str2", null], null]' "[[String!]]" | '[["str"], ["str2", "str3"], null]' "WEEKDAY" | 'MONDAY' "UserInput" | '{ fieldNonNull: "str" }' From 57a64cbea685d6ce919d32406ab8d85dca00e559 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 May 2024 16:17:13 +0000 Subject: [PATCH 19/67] Bump net.bytebuddy:byte-buddy-agent from 1.14.15 to 1.14.16 Bumps [net.bytebuddy:byte-buddy-agent](https://github.com/raphw/byte-buddy) from 1.14.15 to 1.14.16. - [Release notes](https://github.com/raphw/byte-buddy/releases) - [Changelog](https://github.com/raphw/byte-buddy/blob/master/release-notes.md) - [Commits](https://github.com/raphw/byte-buddy/compare/byte-buddy-1.14.15...byte-buddy-1.14.16) --- updated-dependencies: - dependency-name: net.bytebuddy:byte-buddy-agent dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- agent-test/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-test/build.gradle b/agent-test/build.gradle index 2ce1e1892c..a9b3b2238b 100644 --- a/agent-test/build.gradle +++ b/agent-test/build.gradle @@ -4,7 +4,7 @@ plugins { dependencies { implementation(rootProject) - implementation("net.bytebuddy:byte-buddy-agent:1.14.15") + implementation("net.bytebuddy:byte-buddy-agent:1.14.16") testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' From f57183637fe9f3cbb63e015f678bc06c94fe6c3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 May 2024 16:17:16 +0000 Subject: [PATCH 20/67] Bump org.assertj:assertj-core from 3.25.3 to 3.26.0 Bumps [org.assertj:assertj-core](https://github.com/assertj/assertj) from 3.25.3 to 3.26.0. - [Release notes](https://github.com/assertj/assertj/releases) - [Commits](https://github.com/assertj/assertj/compare/assertj-build-3.25.3...assertj-build-3.26.0) --- updated-dependencies: - dependency-name: org.assertj:assertj-core dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- agent-test/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-test/build.gradle b/agent-test/build.gradle index 2ce1e1892c..fc47f61c3b 100644 --- a/agent-test/build.gradle +++ b/agent-test/build.gradle @@ -9,7 +9,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testImplementation("org.assertj:assertj-core:3.25.3") + testImplementation("org.assertj:assertj-core:3.26.0") } From 90599b92aa2d2ed3f76dff8818e351d157aeb9ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 May 2024 16:17:20 +0000 Subject: [PATCH 21/67] Bump net.bytebuddy:byte-buddy from 1.14.15 to 1.14.16 Bumps [net.bytebuddy:byte-buddy](https://github.com/raphw/byte-buddy) from 1.14.15 to 1.14.16. - [Release notes](https://github.com/raphw/byte-buddy/releases) - [Changelog](https://github.com/raphw/byte-buddy/blob/master/release-notes.md) - [Commits](https://github.com/raphw/byte-buddy/compare/byte-buddy-1.14.15...byte-buddy-1.14.16) --- updated-dependencies: - dependency-name: net.bytebuddy:byte-buddy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- agent/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/build.gradle b/agent/build.gradle index c81c0e4d7c..89f6a0ca72 100644 --- a/agent/build.gradle +++ b/agent/build.gradle @@ -6,7 +6,7 @@ plugins { } dependencies { - implementation("net.bytebuddy:byte-buddy:1.14.15") + implementation("net.bytebuddy:byte-buddy:1.14.16") // graphql-java itself implementation(rootProject) } From 855d10bdcd3abd01808257f0f1bb1cb6b0dcd6aa Mon Sep 17 00:00:00 2001 From: bbaker Date: Sat, 1 Jun 2024 16:28:42 +1000 Subject: [PATCH 22/67] Better javadoc on beginFieldFetching --- .../FieldFetchingInstrumentationContext.java | 39 ++++++++++--------- .../instrumentation/Instrumentation.java | 5 +++ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/main/java/graphql/execution/instrumentation/FieldFetchingInstrumentationContext.java b/src/main/java/graphql/execution/instrumentation/FieldFetchingInstrumentationContext.java index d8e51269be..c64ea80ba5 100644 --- a/src/main/java/graphql/execution/instrumentation/FieldFetchingInstrumentationContext.java +++ b/src/main/java/graphql/execution/instrumentation/FieldFetchingInstrumentationContext.java @@ -2,12 +2,30 @@ import graphql.Internal; import graphql.PublicSpi; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +/** + * FieldFetchingInstrumentationContext is returned back from the {@link Instrumentation#beginFieldFetching(InstrumentationFieldFetchParameters, InstrumentationState)} + * method, and it's much like the normal {@link InstrumentationContext} type except it also + * gives the value that was returned by a fields {@link graphql.schema.DataFetcher}. This allows + * you to know if the field value is a completely materialised field or if it's a {@link java.util.concurrent.CompletableFuture} + * promise to a value. + */ @PublicSpi public interface FieldFetchingInstrumentationContext extends InstrumentationContext { + /** + * This is called back with the value fetched for the field by its {@link graphql.schema.DataFetcher}. + * This can be a materialised java object or it maybe a {@link java.util.concurrent.CompletableFuture} + * promise to some async value that has not yet completed. + * + * @param fetchedValue a value that a field's {@link graphql.schema.DataFetcher} returned + */ + default void onFetchedValue(Object fetchedValue) { + } + @Internal FieldFetchingInstrumentationContext NOOP = new FieldFetchingInstrumentationContext() { @Override @@ -17,18 +35,13 @@ public void onDispatched() { @Override public void onCompleted(Object result, Throwable t) { } - - @Override - public void onFetchedValue(Object fetchedValue) { - } }; /** - * This creates a no-op {@link InstrumentationContext} if the one pass in is null + * This creates a no-op {@link InstrumentationContext} if the one passed in is null * * @param nullableContext a {@link InstrumentationContext} that can be null - * - * @return a non null {@link InstrumentationContext} that maybe a no-op + * @return a non-null {@link InstrumentationContext} that maybe a no-op */ @NotNull @Internal @@ -51,18 +64,6 @@ public void onDispatched() { public void onCompleted(Object result, Throwable t) { context.onCompleted(result, t); } - - @Override - public void onFetchedValue(Object fetchedValue) { - } }; } - - /** - * This is called back with value fetched for the field. - * - * @param fetchedValue a value that a field's {@link graphql.schema.DataFetcher} returned - */ - default void onFetchedValue(Object fetchedValue) { - } } diff --git a/src/main/java/graphql/execution/instrumentation/Instrumentation.java b/src/main/java/graphql/execution/instrumentation/Instrumentation.java index 11819347ad..4f7767a767 100644 --- a/src/main/java/graphql/execution/instrumentation/Instrumentation.java +++ b/src/main/java/graphql/execution/instrumentation/Instrumentation.java @@ -209,6 +209,11 @@ default InstrumentationContext beginFieldFetch(InstrumentationFieldFetch * This is called just before a field {@link DataFetcher} is invoked. The {@link FieldFetchingInstrumentationContext#onFetchedValue(Object)} * callback will be invoked once a value is returned by a {@link DataFetcher} but perhaps before * its value is completed if it's a {@link CompletableFuture} value. + *

+ * This method is the replacement method for the now deprecated {@link #beginFieldFetch(InstrumentationFieldFetchParameters, InstrumentationState)} + * method, and it should be implemented in new {@link Instrumentation} classes. This default version of this + * method calls back to the deprecated {@link #beginFieldFetch(InstrumentationFieldFetchParameters, InstrumentationState)} method + * so that older implementations continue to work. * * @param parameters the parameters to this step * @param state the state created during the call to {@link #createStateAsync(InstrumentationCreateStateParameters)} From d40a353a147c70bed8672c45d6a28e13d2512616 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Sat, 1 Jun 2024 17:55:21 +1000 Subject: [PATCH 23/67] Update src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy Co-authored-by: dondonz <13839920+dondonz@users.noreply.github.com> --- .../graphql/incremental/IncrementalExecutionResultTest.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy b/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy index 6770a6b8ce..9bf954cb63 100644 --- a/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy +++ b/src/test/groovy/graphql/incremental/IncrementalExecutionResultTest.groovy @@ -118,6 +118,6 @@ class IncrementalExecutionResultTest extends Specification { newIncrementalExecutionResult.errors == incrementalExecutionResult.errors newIncrementalExecutionResult.incrementalItemPublisher == incrementalExecutionResult.incrementalItemPublisher newIncrementalExecutionResult.hasNext() == incrementalExecutionResult.hasNext() - newIncrementalExecutionResult.toSpecification() == newIncrementalExecutionResult.toSpecification() + newIncrementalExecutionResult.toSpecification() == incrementalExecutionResult.toSpecification() } } From bb68e044760f6dca6e21e46fb260e72c63334e62 Mon Sep 17 00:00:00 2001 From: Brad Baker Date: Sun, 2 Jun 2024 18:21:32 +1000 Subject: [PATCH 24/67] Update src/main/java/graphql/validation/ValidationUtil.java Co-authored-by: dondonz <13839920+dondonz@users.noreply.github.com> --- src/main/java/graphql/validation/ValidationUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/graphql/validation/ValidationUtil.java b/src/main/java/graphql/validation/ValidationUtil.java index 1c9ce92b6a..1bff274d92 100644 --- a/src/main/java/graphql/validation/ValidationUtil.java +++ b/src/main/java/graphql/validation/ValidationUtil.java @@ -161,7 +161,7 @@ boolean isValidLiteralValueForInputObjectType(Value value, GraphQLInputObject } if (type.hasAppliedDirective(Directives.OneOfDirective.getName())) { - if (objectFieldMap.keySet() .size() != 1) { + if (objectFieldMap.keySet().size() != 1) { handleExtraOneOfFieldsError(type,value); return false; } From d8e16221ea43955af9f1081701428dbc25b67a60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 16:12:37 +0000 Subject: [PATCH 25/67] Bump net.bytebuddy:byte-buddy from 1.14.16 to 1.14.17 Bumps [net.bytebuddy:byte-buddy](https://github.com/raphw/byte-buddy) from 1.14.16 to 1.14.17. - [Release notes](https://github.com/raphw/byte-buddy/releases) - [Changelog](https://github.com/raphw/byte-buddy/blob/master/release-notes.md) - [Commits](https://github.com/raphw/byte-buddy/compare/byte-buddy-1.14.16...byte-buddy-1.14.17) --- updated-dependencies: - dependency-name: net.bytebuddy:byte-buddy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- agent/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/build.gradle b/agent/build.gradle index 89f6a0ca72..1c36c86288 100644 --- a/agent/build.gradle +++ b/agent/build.gradle @@ -6,7 +6,7 @@ plugins { } dependencies { - implementation("net.bytebuddy:byte-buddy:1.14.16") + implementation("net.bytebuddy:byte-buddy:1.14.17") // graphql-java itself implementation(rootProject) } From 72fc78c62cd25ddcb974ceb7fd3294d4f7d24abe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 16:12:45 +0000 Subject: [PATCH 26/67] Bump net.bytebuddy:byte-buddy-agent from 1.14.16 to 1.14.17 Bumps [net.bytebuddy:byte-buddy-agent](https://github.com/raphw/byte-buddy) from 1.14.16 to 1.14.17. - [Release notes](https://github.com/raphw/byte-buddy/releases) - [Changelog](https://github.com/raphw/byte-buddy/blob/master/release-notes.md) - [Commits](https://github.com/raphw/byte-buddy/compare/byte-buddy-1.14.16...byte-buddy-1.14.17) --- updated-dependencies: - dependency-name: net.bytebuddy:byte-buddy-agent dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- agent-test/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-test/build.gradle b/agent-test/build.gradle index 22a303a111..a968428d36 100644 --- a/agent-test/build.gradle +++ b/agent-test/build.gradle @@ -4,7 +4,7 @@ plugins { dependencies { implementation(rootProject) - implementation("net.bytebuddy:byte-buddy-agent:1.14.16") + implementation("net.bytebuddy:byte-buddy-agent:1.14.17") testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' From 1583988203a2d463f13ce674747b25f40e7986cf Mon Sep 17 00:00:00 2001 From: dondonz <13839920+dondonz@users.noreply.github.com> Date: Tue, 4 Jun 2024 17:17:13 +1000 Subject: [PATCH 27/67] Fix stale bot cache problem --- .github/workflows/stale-pr-issue.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/stale-pr-issue.yml b/.github/workflows/stale-pr-issue.yml index f36da60e2a..540f31aa80 100644 --- a/.github/workflows/stale-pr-issue.yml +++ b/.github/workflows/stale-pr-issue.yml @@ -8,6 +8,7 @@ on: - cron: '0 0 * * *' permissions: + actions: write issues: write pull-requests: write From 371a7bf4b75a602e7faa40e14bbcce0c8477400d Mon Sep 17 00:00:00 2001 From: dondonz <13839920+dondonz@users.noreply.github.com> Date: Wed, 5 Jun 2024 21:43:54 +1000 Subject: [PATCH 28/67] Add tests for 3 flavours of messages --- .../graphql/parser/ParserOptionsTest.groovy | 13 +++- .../groovy/graphql/parser/ParserTest.groovy | 66 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/test/groovy/graphql/parser/ParserOptionsTest.groovy b/src/test/groovy/graphql/parser/ParserOptionsTest.groovy index 83ed13e9c6..9d43543f89 100644 --- a/src/test/groovy/graphql/parser/ParserOptionsTest.groovy +++ b/src/test/groovy/graphql/parser/ParserOptionsTest.groovy @@ -30,13 +30,15 @@ class ParserOptionsTest extends Specification { defaultOptions.isCaptureLineComments() !defaultOptions.isCaptureIgnoredChars() defaultOptions.isReaderTrackData() + !defaultOptions.isRedactTokenParserErrorMessages() defaultOperationOptions.getMaxTokens() == 15_000 defaultOperationOptions.getMaxWhitespaceTokens() == 200_000 defaultOperationOptions.isCaptureSourceLocation() !defaultOperationOptions.isCaptureLineComments() !defaultOperationOptions.isCaptureIgnoredChars() - defaultOptions.isReaderTrackData() + defaultOperationOptions.isReaderTrackData() + !defaultOperationOptions.isRedactTokenParserErrorMessages() defaultSdlOptions.getMaxCharacters() == Integer.MAX_VALUE defaultSdlOptions.getMaxTokens() == Integer.MAX_VALUE @@ -44,14 +46,16 @@ class ParserOptionsTest extends Specification { defaultSdlOptions.isCaptureSourceLocation() defaultSdlOptions.isCaptureLineComments() !defaultSdlOptions.isCaptureIgnoredChars() - defaultOptions.isReaderTrackData() + defaultSdlOptions.isReaderTrackData() + !defaultSdlOptions.isRedactTokenParserErrorMessages() } def "can set in new option JVM wide"() { def newDefaultOptions = defaultOptions.transform({ it.captureIgnoredChars(true) .readerTrackData(false) - } ) + .redactTokenParserErrorMessages(true) + }) def newDefaultOperationOptions = defaultOperationOptions.transform( { it.captureIgnoredChars(true) @@ -84,6 +88,7 @@ class ParserOptionsTest extends Specification { currentDefaultOptions.isCaptureLineComments() currentDefaultOptions.isCaptureIgnoredChars() !currentDefaultOptions.isReaderTrackData() + currentDefaultOptions.isRedactTokenParserErrorMessages() currentDefaultOperationOptions.getMaxCharacters() == 1_000_000 currentDefaultOperationOptions.getMaxTokens() == 15_000 @@ -92,6 +97,7 @@ class ParserOptionsTest extends Specification { currentDefaultOperationOptions.isCaptureLineComments() currentDefaultOperationOptions.isCaptureIgnoredChars() currentDefaultOperationOptions.isReaderTrackData() + !currentDefaultOperationOptions.isRedactTokenParserErrorMessages() currentDefaultSdlOptions.getMaxCharacters() == Integer.MAX_VALUE currentDefaultSdlOptions.getMaxTokens() == Integer.MAX_VALUE @@ -100,5 +106,6 @@ class ParserOptionsTest extends Specification { currentDefaultSdlOptions.isCaptureLineComments() currentDefaultSdlOptions.isCaptureIgnoredChars() currentDefaultSdlOptions.isReaderTrackData() + !currentDefaultSdlOptions.isRedactTokenParserErrorMessages() } } diff --git a/src/test/groovy/graphql/parser/ParserTest.groovy b/src/test/groovy/graphql/parser/ParserTest.groovy index 42e604e9a6..d593f037ca 100644 --- a/src/test/groovy/graphql/parser/ParserTest.groovy +++ b/src/test/groovy/graphql/parser/ParserTest.groovy @@ -1188,4 +1188,70 @@ triple3 : """edge cases \\""" "" " \\"" \\" edge cases""" "\"\t\" scalar A" | _ } + def "can redact tokens in InvalidSyntax parser error message"() { + given: + def input = '''""" scalar ComputerSaysNo''' + + when: // Default options do not redact error messages + Parser.parse(input) + + then: + InvalidSyntaxException e = thrown(InvalidSyntaxException) + e.message == '''Invalid syntax with ANTLR error 'token recognition error at: '""" scalar ComputerSaysNo'' at line 1 column 1''' + + when: // Enable redacted parser error messages + def redactParserErrorMessages = ParserOptions.newParserOptions().redactTokenParserErrorMessages(true).build() + def parserEnvironment = newParserEnvironment().document(input).parserOptions(redactParserErrorMessages).build() + new Parser().parseDocument(parserEnvironment) + + then: + InvalidSyntaxException redactedError = thrown(InvalidSyntaxException) + redactedError.message == "Invalid syntax at line 1 column 1" + } + + def "can redact tokens in InvalidSyntaxBail parser error message"() { + given: + def input = ''' + query { + computer says no!!!!!! + ''' + + when: // Default options do not redact error messages + Parser.parse(input) + + then: + InvalidSyntaxException e = thrown(InvalidSyntaxException) + e.message == "Invalid syntax with offending token '!' at line 3 column 31" + + when: // Enable redacted parser error messages + def redactParserErrorMessages = ParserOptions.newParserOptions().redactTokenParserErrorMessages(true).build() + def parserEnvironment = newParserEnvironment().document(input).parserOptions(redactParserErrorMessages).build() + new Parser().parseDocument(parserEnvironment) + + then: + InvalidSyntaxException redactedError = thrown(InvalidSyntaxException) + redactedError.message == "Invalid syntax at line 3 column 31" + } + + def "can redact tokens in InvalidSyntaxMoreTokens parser error message"() { + given: + def input = "{profile(id:117) {computer, says, no}}}" + + + when: // Default options do not redact error messages + Parser.parse(input) + + then: + InvalidSyntaxException e = thrown(InvalidSyntaxException) + e.message == "Invalid syntax encountered. There are extra tokens in the text that have not been consumed. Offending token '}' at line 1 column 39" + + when: // Enable redacted parser error messages + def redactParserErrorMessages = ParserOptions.newParserOptions().redactTokenParserErrorMessages(true).build() + def parserEnvironment = newParserEnvironment().document(input).parserOptions(redactParserErrorMessages).build() + new Parser().parseDocument(parserEnvironment) + + then: + InvalidSyntaxException redactedError = thrown(InvalidSyntaxException) + redactedError.message == "Invalid syntax encountered. There are extra tokens in the text that have not been consumed. Offending token at line 1 column 39" + } } From 0e54b40adfb968c0cc42c979f625446dd0071a15 Mon Sep 17 00:00:00 2001 From: dondonz <13839920+dondonz@users.noreply.github.com> Date: Wed, 5 Jun 2024 21:44:18 +1000 Subject: [PATCH 29/67] Add message variant without token information --- src/main/resources/i18n/Parsing.properties | 1 + src/main/resources/i18n/Parsing_de.properties | 1 + src/main/resources/i18n/Parsing_nl.properties | 1 + 3 files changed, 3 insertions(+) diff --git a/src/main/resources/i18n/Parsing.properties b/src/main/resources/i18n/Parsing.properties index 91a7f276f4..474fba1545 100644 --- a/src/main/resources/i18n/Parsing.properties +++ b/src/main/resources/i18n/Parsing.properties @@ -16,6 +16,7 @@ InvalidSyntax.full=Invalid syntax with ANTLR error ''{0}'' at line {1} column {2 InvalidSyntaxBail.noToken=Invalid syntax at line {0} column {1} InvalidSyntaxBail.full=Invalid syntax with offending token ''{0}'' at line {1} column {2} # +InvalidSyntaxMoreTokens.noMessage=Invalid syntax encountered. There are extra tokens in the text that have not been consumed. Offending token at line {0} column {1} InvalidSyntaxMoreTokens.full=Invalid syntax encountered. There are extra tokens in the text that have not been consumed. Offending token ''{0}'' at line {1} column {2} # ParseCancelled.full=More than {0} ''{1}'' tokens have been presented. To prevent Denial Of Service attacks, parsing has been cancelled. diff --git a/src/main/resources/i18n/Parsing_de.properties b/src/main/resources/i18n/Parsing_de.properties index 2d3c43f777..55127ff689 100644 --- a/src/main/resources/i18n/Parsing_de.properties +++ b/src/main/resources/i18n/Parsing_de.properties @@ -16,6 +16,7 @@ InvalidSyntax.full=Ungültige Syntax, ANTLR-Fehler ''{0}'' in Zeile {1} Spalte { InvalidSyntaxBail.noToken=Ungültige Syntax in Zeile {0} Spalte {1} InvalidSyntaxBail.full=Ungültige Syntax wegen des ungültigen Tokens ''{0}'' in Zeile {1} Spalte {2} # +InvalidSyntaxMoreTokens.noMessage=Es wurde eine ungültige Syntax festgestellt. Es gibt zusätzliche Token im Text, die nicht konsumiert wurden. Ungültiges Token in Zeile {0} Spalte {1} InvalidSyntaxMoreTokens.full=Es wurde eine ungültige Syntax festgestellt. Es gibt zusätzliche Token im Text, die nicht konsumiert wurden. Ungültiges Token ''{0}'' in Zeile {1} Spalte {2} # ParseCancelled.full=Es wurden mehr als {0} ''{1}'' Token präsentiert. Um Denial-of-Service-Angriffe zu verhindern, wurde das Parsing abgebrochen. diff --git a/src/main/resources/i18n/Parsing_nl.properties b/src/main/resources/i18n/Parsing_nl.properties index dfa099a91a..cfd8457825 100644 --- a/src/main/resources/i18n/Parsing_nl.properties +++ b/src/main/resources/i18n/Parsing_nl.properties @@ -15,6 +15,7 @@ InvalidSyntax.full=Ongeldige syntaxis, ANTLR foutmelding ''{0}'' op lijn {1} kol InvalidSyntaxBail.noToken=Ongeldige syntaxis op lijn {0} kolom {1} InvalidSyntaxBail.full=Ongeldige syntaxis wegens ongeldige token ''{0}'' op lijn {1} kolom {2} # +InvalidSyntaxMoreTokens.noMessage=Ongeldige syntaxis tegengekomen. Er zijn tokens in de tekst die niet zijn verwerkt. Ongeldige token op lijn {0} kolom {1} InvalidSyntaxMoreTokens.full=Ongeldige syntaxis tegengekomen. Er zijn tokens in de tekst die niet zijn verwerkt. Ongeldige token ''{0}'' op lijn {1} kolom {2} # ParseCancelled.full=Meer dan {0} ''{1}'' tokens zijn gepresenteerd. Om een DDoS-aanval te voorkomen is het parsen gestopt. From 8ed9685405dd2afa73b6d0fda27940c09287762a Mon Sep 17 00:00:00 2001 From: dondonz <13839920+dondonz@users.noreply.github.com> Date: Wed, 5 Jun 2024 21:44:51 +1000 Subject: [PATCH 30/67] Add redacted parser error message option --- .../java/graphql/parser/ParserOptions.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/java/graphql/parser/ParserOptions.java b/src/main/java/graphql/parser/ParserOptions.java index 965e71876d..c3366d6bb6 100644 --- a/src/main/java/graphql/parser/ParserOptions.java +++ b/src/main/java/graphql/parser/ParserOptions.java @@ -62,6 +62,7 @@ public class ParserOptions { .maxTokens(MAX_QUERY_TOKENS) // to prevent a billion laughs style attacks, we set a default for graphql-java .maxWhitespaceTokens(MAX_WHITESPACE_TOKENS) .maxRuleDepth(MAX_RULE_DEPTH) + .redactTokenParserErrorMessages(false) .build(); private static ParserOptions defaultJvmOperationParserOptions = newParserOptions() @@ -73,6 +74,7 @@ public class ParserOptions { .maxTokens(MAX_QUERY_TOKENS) // to prevent a billion laughs style attacks, we set a default for graphql-java .maxWhitespaceTokens(MAX_WHITESPACE_TOKENS) .maxRuleDepth(MAX_RULE_DEPTH) + .redactTokenParserErrorMessages(false) .build(); private static ParserOptions defaultJvmSdlParserOptions = newParserOptions() @@ -84,6 +86,7 @@ public class ParserOptions { .maxTokens(Integer.MAX_VALUE) // we are less worried about a billion laughs with SDL parsing since the call path is not facing attackers .maxWhitespaceTokens(Integer.MAX_VALUE) .maxRuleDepth(Integer.MAX_VALUE) + .redactTokenParserErrorMessages(false) .build(); /** @@ -189,6 +192,7 @@ public static void setDefaultSdlParserOptions(ParserOptions options) { private final int maxTokens; private final int maxWhitespaceTokens; private final int maxRuleDepth; + private final boolean redactTokenParserErrorMessages; private final ParsingListener parsingListener; private ParserOptions(Builder builder) { @@ -200,6 +204,7 @@ private ParserOptions(Builder builder) { this.maxTokens = builder.maxTokens; this.maxWhitespaceTokens = builder.maxWhitespaceTokens; this.maxRuleDepth = builder.maxRuleDepth; + this.redactTokenParserErrorMessages = builder.redactTokenParserErrorMessages; this.parsingListener = builder.parsingListener; } @@ -294,6 +299,14 @@ public int getMaxRuleDepth() { return maxRuleDepth; } + /** + * Option to redact offending tokens in parser error messages. + * By default, the parser will include the offending token in the error message, if possible. + */ + public boolean isRedactTokenParserErrorMessages() { + return redactTokenParserErrorMessages; + } + public ParsingListener getParsingListener() { return parsingListener; } @@ -319,6 +332,7 @@ public static class Builder { private int maxTokens = MAX_QUERY_TOKENS; private int maxWhitespaceTokens = MAX_WHITESPACE_TOKENS; private int maxRuleDepth = MAX_RULE_DEPTH; + private boolean redactTokenParserErrorMessages = false; Builder() { } @@ -331,6 +345,7 @@ public static class Builder { this.maxTokens = parserOptions.maxTokens; this.maxWhitespaceTokens = parserOptions.maxWhitespaceTokens; this.maxRuleDepth = parserOptions.maxRuleDepth; + this.redactTokenParserErrorMessages = parserOptions.redactTokenParserErrorMessages; this.parsingListener = parserOptions.parsingListener; } @@ -374,6 +389,11 @@ public Builder maxRuleDepth(int maxRuleDepth) { return this; } + public Builder redactTokenParserErrorMessages(boolean redactTokenParserErrorMessages) { + this.redactTokenParserErrorMessages = redactTokenParserErrorMessages; + return this; + } + public Builder parsingListener(ParsingListener parsingListener) { this.parsingListener = assertNotNull(parsingListener); return this; From 0217aeac26b53b34fe928c05b21f53182cf06946 Mon Sep 17 00:00:00 2001 From: dondonz <13839920+dondonz@users.noreply.github.com> Date: Wed, 5 Jun 2024 21:45:28 +1000 Subject: [PATCH 31/67] Add toggle to redact parser error messages --- src/main/java/graphql/parser/ExtendedBailStrategy.java | 6 +++++- src/main/java/graphql/parser/Parser.java | 2 +- .../parser/exceptions/MoreTokensSyntaxException.java | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/graphql/parser/ExtendedBailStrategy.java b/src/main/java/graphql/parser/ExtendedBailStrategy.java index a0861ed0c1..8a83904402 100644 --- a/src/main/java/graphql/parser/ExtendedBailStrategy.java +++ b/src/main/java/graphql/parser/ExtendedBailStrategy.java @@ -42,6 +42,10 @@ public Token recoverInline(Parser recognizer) throws RecognitionException { InvalidSyntaxException mkMoreTokensException(Token token) { SourceLocation sourceLocation = AntlrHelper.createSourceLocation(multiSourceReader, token); + if (environment.getParserOptions().isRedactTokenParserErrorMessages()) { + return new MoreTokensSyntaxException(environment.getI18N(), sourceLocation); + } + String sourcePreview = AntlrHelper.createPreview(multiSourceReader, token.getLine()); return new MoreTokensSyntaxException(environment.getI18N(), sourceLocation, token.getText(), sourcePreview); @@ -66,7 +70,7 @@ private InvalidSyntaxException mkException(Parser recognizer, RecognitionExcepti String msgKey; List args; SourceLocation location = sourceLocation == null ? SourceLocation.EMPTY : sourceLocation; - if (offendingToken == null) { + if (offendingToken == null || environment.getParserOptions().isRedactTokenParserErrorMessages()) { msgKey = "InvalidSyntaxBail.noToken"; args = ImmutableList.of(location.getLine(), location.getColumn()); } else { diff --git a/src/main/java/graphql/parser/Parser.java b/src/main/java/graphql/parser/Parser.java index 15a0f5f641..433442b7fb 100644 --- a/src/main/java/graphql/parser/Parser.java +++ b/src/main/java/graphql/parser/Parser.java @@ -301,7 +301,7 @@ public void syntaxError(Recognizer recognizer, Object offendingSymbol, int String preview = AntlrHelper.createPreview(multiSourceReader, line); String msgKey; List args; - if (antlerMsg == null) { + if (antlerMsg == null || environment.getParserOptions().isRedactTokenParserErrorMessages()) { msgKey = "InvalidSyntax.noMessage"; args = ImmutableList.of(sourceLocation.getLine(), sourceLocation.getColumn()); } else { diff --git a/src/main/java/graphql/parser/exceptions/MoreTokensSyntaxException.java b/src/main/java/graphql/parser/exceptions/MoreTokensSyntaxException.java index 6f73b38e4c..a1378b2c85 100644 --- a/src/main/java/graphql/parser/exceptions/MoreTokensSyntaxException.java +++ b/src/main/java/graphql/parser/exceptions/MoreTokensSyntaxException.java @@ -14,4 +14,11 @@ public MoreTokensSyntaxException(@NotNull I18n i18N, @NotNull SourceLocation sou super(i18N.msg("InvalidSyntaxMoreTokens.full", offendingToken, sourceLocation.getLine(), sourceLocation.getColumn()), sourceLocation, offendingToken, sourcePreview, null); } + + @Internal + public MoreTokensSyntaxException(@NotNull I18n i18N, @NotNull SourceLocation sourceLocation) { + super(i18N.msg("InvalidSyntaxMoreTokens.noMessage", sourceLocation.getLine(), sourceLocation.getColumn()), + sourceLocation, null, null, null); + } + } From a9f53dd2c27927093e1c792412008a7ed5039df6 Mon Sep 17 00:00:00 2001 From: Antoine Boyer Date: Wed, 5 Jun 2024 15:42:08 -0700 Subject: [PATCH 32/67] AstPrinter: Empty types should not include braces `{}` --- .../java/graphql/language/AstPrinter.java | 2 +- .../graphql/language/AstPrinterTest.groovy | 26 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/java/graphql/language/AstPrinter.java b/src/main/java/graphql/language/AstPrinter.java index b48fa2075d..3315c1302c 100644 --- a/src/main/java/graphql/language/AstPrinter.java +++ b/src/main/java/graphql/language/AstPrinter.java @@ -619,7 +619,7 @@ String wrap(String start, String maybeString, String end) { private String block(List nodes) { if (isEmpty(nodes)) { - return "{}"; + return ""; } if (compactMode) { String joinedNodes = joinTight(nodes, " ", "", ""); diff --git a/src/test/groovy/graphql/language/AstPrinterTest.groovy b/src/test/groovy/graphql/language/AstPrinterTest.groovy index aed976ee02..e0d57aad67 100644 --- a/src/test/groovy/graphql/language/AstPrinterTest.groovy +++ b/src/test/groovy/graphql/language/AstPrinterTest.groovy @@ -696,7 +696,7 @@ extend input Input @directive { def result = AstPrinter.printAstCompact(interfaceType) then: - result == "interface Resource implements Node & Extra {}" + result == "interface Resource implements Node & Extra" } @@ -713,7 +713,7 @@ extend input Input @directive { def result = AstPrinter.printAstCompact(interfaceType) then: - result == "extend interface Resource implements Node & Extra {}" + result == "extend interface Resource implements Node & Extra" } @@ -747,4 +747,26 @@ extend input Input @directive { result == "directive @d2 on FIELD | ENUM" } + + def "empty type does not include braces"() { + def sdl = "type Query" + def document = parse(sdl) + + when: + String output = printAst(document) + then: + output == "type Query\n" + } + + def "empty selection set does not include braces"() { + // technically below is not valid graphql and will never be parsed as is + def field_with_empty_selection_set = Field.newField("foo") + .selectionSet(SelectionSet.newSelectionSet().build()) + .build() + + when: + String output = printAst(field_with_empty_selection_set) + then: + output == "foo" + } } From 5f975d457003c7167627b81c1861ed1341d45bd4 Mon Sep 17 00:00:00 2001 From: bbaker Date: Sat, 8 Jun 2024 21:27:41 +1000 Subject: [PATCH 33/67] Make FetchedValue a public constructor --- src/main/java/graphql/execution/FetchedValue.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/graphql/execution/FetchedValue.java b/src/main/java/graphql/execution/FetchedValue.java index 0a643f8b71..8ebac38ced 100644 --- a/src/main/java/graphql/execution/FetchedValue.java +++ b/src/main/java/graphql/execution/FetchedValue.java @@ -17,7 +17,7 @@ public class FetchedValue { private final Object localContext; private final ImmutableList errors; - FetchedValue(Object fetchedValue, List errors, Object localContext) { + public FetchedValue(Object fetchedValue, List errors, Object localContext) { this.fetchedValue = fetchedValue; this.errors = ImmutableList.copyOf(errors); this.localContext = localContext; From bd308ac6d13a07daa5f753d4cc1aff670c7160df Mon Sep 17 00:00:00 2001 From: Oliver Lockwood Date: Wed, 12 Jun 2024 11:03:44 +0100 Subject: [PATCH 34/67] Add toString() implementations for RawVariables and CoercedVariables classes --- src/main/java/graphql/execution/CoercedVariables.java | 5 +++++ src/main/java/graphql/execution/RawVariables.java | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/main/java/graphql/execution/CoercedVariables.java b/src/main/java/graphql/execution/CoercedVariables.java index a0d8c038dd..feeed4fb56 100644 --- a/src/main/java/graphql/execution/CoercedVariables.java +++ b/src/main/java/graphql/execution/CoercedVariables.java @@ -36,4 +36,9 @@ public static CoercedVariables emptyVariables() { public static CoercedVariables of(Map coercedVariables) { return new CoercedVariables(coercedVariables); } + + @Override + public String toString() { + return coercedVariables.toString(); + } } diff --git a/src/main/java/graphql/execution/RawVariables.java b/src/main/java/graphql/execution/RawVariables.java index f8442fc5b9..02e1acabe2 100644 --- a/src/main/java/graphql/execution/RawVariables.java +++ b/src/main/java/graphql/execution/RawVariables.java @@ -36,4 +36,9 @@ public static RawVariables emptyVariables() { public static RawVariables of(Map rawVariables) { return new RawVariables(rawVariables); } + + @Override + public String toString() { + return rawVariables.toString(); + } } From dcf4b1125adeb922d8516d31e2516d5bba7b7808 Mon Sep 17 00:00:00 2001 From: bbaker Date: Mon, 17 Jun 2024 18:25:26 +1000 Subject: [PATCH 35/67] Start draining deferred results on subscription --- .../incremental/IncrementalCallState.java | 22 +++++++++++++------ .../IncrementalCallStateDeferTest.groovy | 3 ++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/java/graphql/execution/incremental/IncrementalCallState.java b/src/main/java/graphql/execution/incremental/IncrementalCallState.java index f96a706f36..45ec6e0b3f 100644 --- a/src/main/java/graphql/execution/incremental/IncrementalCallState.java +++ b/src/main/java/graphql/execution/incremental/IncrementalCallState.java @@ -4,6 +4,7 @@ import graphql.execution.reactive.SingleSubscriberPublisher; import graphql.incremental.DelayedIncrementalPartialResult; import graphql.incremental.IncrementalPayload; +import graphql.util.InterThreadMemoizedSupplier; import graphql.util.LockKit; import org.reactivestreams.Publisher; @@ -13,6 +14,7 @@ import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; import static graphql.incremental.DelayedIncrementalPartialResultImpl.newIncrementalExecutionResult; @@ -24,7 +26,7 @@ public class IncrementalCallState { private final AtomicBoolean incrementalCallsDetected = new AtomicBoolean(false); private final Deque> incrementalCalls = new ConcurrentLinkedDeque<>(); - private final SingleSubscriberPublisher publisher = new SingleSubscriberPublisher<>(); + private final Supplier> publisher = createPublisher(); private final AtomicInteger pendingCalls = new AtomicInteger(); private final LockKit.ReentrantLock publisherLock = new LockKit.ReentrantLock(); @@ -36,7 +38,7 @@ private void drainIncrementalCalls() { incrementalCall.invoke() .whenComplete((payload, exception) -> { if (exception != null) { - publisher.offerError(exception); + publisher.get().offerError(exception); return; } @@ -53,13 +55,13 @@ private void drainIncrementalCalls() { .hasNext(remainingCalls != 0) .build(); - publisher.offer(executionResult); + publisher.get().offer(executionResult); } finally { publisherLock.unlock(); } if (remainingCalls == 0) { - publisher.noMoreData(); + publisher.get().noMoreData(); } else { // Nested calls were added, let's try to drain the queue again. drainIncrementalCalls(); @@ -85,13 +87,19 @@ public boolean getIncrementalCallsDetected() { return incrementalCallsDetected.get(); } + private Supplier> createPublisher() { + // this will be created once and once only - any extra calls to .get() will return the previously created + // singleton object + return new InterThreadMemoizedSupplier<>(() -> new SingleSubscriberPublisher<>(this::drainIncrementalCalls)); + } + /** - * When this is called the deferred execution will begin + * When this is called the deferred execution will begin once the {@link Publisher} is subscribed + * to. * * @return the publisher of deferred results */ public Publisher startDeferredCalls() { - drainIncrementalCalls(); - return publisher; + return publisher.get(); } } diff --git a/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy b/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy index 8634458122..5262744b75 100644 --- a/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy +++ b/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy @@ -3,6 +3,7 @@ package graphql.execution.incremental import graphql.ExecutionResultImpl import graphql.execution.ResultPath +import graphql.execution.pubsub.CapturingSubscriber import graphql.incremental.DelayedIncrementalPartialResult import org.awaitility.Awaitility import spock.lang.Specification @@ -239,7 +240,7 @@ class IncrementalCallStateDeferTest extends Specification { } private static List startAndWaitCalls(IncrementalCallState incrementalCallState) { - def subscriber = new graphql.execution.pubsub.CapturingSubscriber() + def subscriber = new CapturingSubscriber() incrementalCallState.startDeferredCalls().subscribe(subscriber) From 1652aeeb188a3d5341b8624eea5e9e2f309a6725 Mon Sep 17 00:00:00 2001 From: bbaker Date: Mon, 17 Jun 2024 20:34:26 +1000 Subject: [PATCH 36/67] Start draining deferred results on subscription - more tests - currently failing --- .../IncrementalCallStateDeferTest.groovy | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy b/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy index 5262744b75..355260a482 100644 --- a/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy +++ b/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy @@ -6,6 +6,7 @@ import graphql.execution.ResultPath import graphql.execution.pubsub.CapturingSubscriber import graphql.incremental.DelayedIncrementalPartialResult import org.awaitility.Awaitility +import org.reactivestreams.Publisher import spock.lang.Specification import java.util.concurrent.CompletableFuture @@ -58,7 +59,7 @@ class IncrementalCallStateDeferTest extends Specification { incrementalCallState.enqueue(offThread("C", 10, "/field/path")) when: - def subscriber = new graphql.execution.pubsub.CapturingSubscriber() { + def subscriber = new CapturingSubscriber() { @Override void onComplete() { assert false, "This should not be called!" @@ -84,7 +85,7 @@ class IncrementalCallStateDeferTest extends Specification { incrementalCallState.enqueue(offThread("C", 10, "/field/path")) // <-- will finish first when: - def subscriber = new graphql.execution.pubsub.CapturingSubscriber() { + def subscriber = new CapturingSubscriber() { @Override void onNext(DelayedIncrementalPartialResult executionResult) { this.getEvents().add(executionResult) @@ -113,8 +114,8 @@ class IncrementalCallStateDeferTest extends Specification { incrementalCallState.enqueue(offThread("C", 10, "/field/path")) // <-- will finish first when: - def subscriber1 = new graphql.execution.pubsub.CapturingSubscriber() - def subscriber2 = new graphql.execution.pubsub.CapturingSubscriber() + def subscriber1 = new CapturingSubscriber() + def subscriber2 = new CapturingSubscriber() incrementalCallState.startDeferredCalls().subscribe(subscriber1) incrementalCallState.startDeferredCalls().subscribe(subscriber2) @@ -196,7 +197,38 @@ class IncrementalCallStateDeferTest extends Specification { results.any { it.incremental[0].data["c"] == "C" } } + def "nothing happens until the publisher is subscribed to"() { + + def startingValue = "*" + given: + def incrementalCallState = new IncrementalCallState() + incrementalCallState.enqueue(offThread({ -> startingValue + "A"}, 100, "/field/path")) // <-- will finish last + incrementalCallState.enqueue(offThread({ -> startingValue + "B"}, 50, "/field/path")) // <-- will finish second + incrementalCallState.enqueue(offThread({ -> startingValue + "C"}, 10, "/field/path")) // <-- will finish first + + when: + + // get the publisher but not work has been done here + def publisher = incrementalCallState.startDeferredCalls() + // we are changing a side effect after the publisher is created + startingValue = "_" + + // subscription wll case the queue publisher to start draining the queue + List results = subscribeAndWaitCalls(publisher) + + then: + assertResultsSizeAndHasNextRule(3, results) + results[0].incremental[0].data["_c"] == "_C" + results[1].incremental[0].data["_b"] == "_B" + results[2].incremental[0].data["_a"] == "_A" + } + + private static DeferredFragmentCall offThread(String data, int sleepTime, String path) { + offThread(() -> data, sleepTime, path) + } + + private static DeferredFragmentCall offThread(Supplier dataSupplier, int sleepTime, String path) { def callSupplier = new Supplier>() { @Override CompletableFuture get() { @@ -205,6 +237,7 @@ class IncrementalCallStateDeferTest extends Specification { if (data == "Bang") { throw new RuntimeException(data) } + String data = dataSupplier.get() new DeferredFragmentCall.FieldWithExecutionResult(data.toLowerCase(), new ExecutionResultImpl(data, [])) }) } @@ -240,10 +273,13 @@ class IncrementalCallStateDeferTest extends Specification { } private static List startAndWaitCalls(IncrementalCallState incrementalCallState) { - def subscriber = new CapturingSubscriber() - - incrementalCallState.startDeferredCalls().subscribe(subscriber) + def publisher = incrementalCallState.startDeferredCalls() + return subscribeAndWaitCalls(publisher) + } + private static List subscribeAndWaitCalls(Publisher publisher) { + def subscriber = new CapturingSubscriber() + publisher.subscribe(subscriber) Awaitility.await().untilTrue(subscriber.isDone()) return subscriber.getEvents() } From f28b5948468fc208cbe28ffbd0f3a3823d7c3b0e Mon Sep 17 00:00:00 2001 From: bbaker Date: Mon, 17 Jun 2024 20:54:40 +1000 Subject: [PATCH 37/67] Start draining deferred results on subscription - more tests - bad code and no error handling --- .../incremental/IncrementalCallStateDeferTest.groovy | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy b/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy index 355260a482..6209fbd4e2 100644 --- a/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy +++ b/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy @@ -234,10 +234,10 @@ class IncrementalCallStateDeferTest extends Specification { CompletableFuture get() { return CompletableFuture.supplyAsync({ Thread.sleep(sleepTime) + String data = dataSupplier.get() if (data == "Bang") { throw new RuntimeException(data) } - String data = dataSupplier.get() new DeferredFragmentCall.FieldWithExecutionResult(data.toLowerCase(), new ExecutionResultImpl(data, [])) }) } @@ -281,6 +281,9 @@ class IncrementalCallStateDeferTest extends Specification { def subscriber = new CapturingSubscriber() publisher.subscribe(subscriber) Awaitility.await().untilTrue(subscriber.isDone()) + if (subscriber.throwable != null) { + throw new RuntimeException(subscriber.throwable) + } return subscriber.getEvents() } } From e1d6417be2a28513fcd4c357a7c6bcd2049eee29 Mon Sep 17 00:00:00 2001 From: bbaker Date: Mon, 17 Jun 2024 22:09:44 +1000 Subject: [PATCH 38/67] Start draining deferred results on subscription - more tests - extra test --- .../IncrementalCallStateDeferTest.groovy | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy b/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy index 6209fbd4e2..94740a9e63 100644 --- a/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy +++ b/src/test/groovy/graphql/execution/incremental/IncrementalCallStateDeferTest.groovy @@ -6,10 +6,14 @@ import graphql.execution.ResultPath import graphql.execution.pubsub.CapturingSubscriber import graphql.incremental.DelayedIncrementalPartialResult import org.awaitility.Awaitility +import org.jetbrains.annotations.NotNull import org.reactivestreams.Publisher import spock.lang.Specification +import java.util.concurrent.Callable import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory import java.util.function.Supplier class IncrementalCallStateDeferTest extends Specification { @@ -202,9 +206,9 @@ class IncrementalCallStateDeferTest extends Specification { def startingValue = "*" given: def incrementalCallState = new IncrementalCallState() - incrementalCallState.enqueue(offThread({ -> startingValue + "A"}, 100, "/field/path")) // <-- will finish last - incrementalCallState.enqueue(offThread({ -> startingValue + "B"}, 50, "/field/path")) // <-- will finish second - incrementalCallState.enqueue(offThread({ -> startingValue + "C"}, 10, "/field/path")) // <-- will finish first + incrementalCallState.enqueue(offThread({ -> startingValue + "A" }, 100, "/field/path")) // <-- will finish last + incrementalCallState.enqueue(offThread({ -> startingValue + "B" }, 50, "/field/path")) // <-- will finish second + incrementalCallState.enqueue(offThread({ -> startingValue + "C" }, 10, "/field/path")) // <-- will finish first when: @@ -223,6 +227,51 @@ class IncrementalCallStateDeferTest extends Specification { results[2].incremental[0].data["_a"] == "_A" } + def "can swap threads on subscribe"() { + + given: + def incrementalCallState = new IncrementalCallState() + incrementalCallState.enqueue(offThread({ -> "A" }, 100, "/field/path")) // <-- will finish last + incrementalCallState.enqueue(offThread({ -> "B" }, 50, "/field/path")) // <-- will finish second + incrementalCallState.enqueue(offThread({ -> "C" }, 10, "/field/path")) // <-- will finish first + + when: + + // get the publisher but not work has been done here + def publisher = incrementalCallState.startDeferredCalls() + + def threadFactory = new ThreadFactory() { + @Override + Thread newThread(@NotNull Runnable r) { + return new Thread(r, "SubscriberThread") + } + } + def executor = Executors.newSingleThreadExecutor(threadFactory) + + def subscribeThreadName = "" + Callable callable = new Callable() { + @Override + Object call() throws Exception { + subscribeThreadName = Thread.currentThread().getName() + def listOfResults = subscribeAndWaitCalls(publisher) + return listOfResults + } + } + def future = executor.submit(callable) + + Awaitility.await().until { future.isDone() } + + then: + def results = future.get() + + // we subscribed on our other thread + subscribeThreadName == "SubscriberThread" + + assertResultsSizeAndHasNextRule(3, results) + results[0].incremental[0].data["c"] == "C" + results[1].incremental[0].data["b"] == "B" + results[2].incremental[0].data["a"] == "A" + } private static DeferredFragmentCall offThread(String data, int sleepTime, String path) { offThread(() -> data, sleepTime, path) From cd17349eddfac4a3f32260444e854b7278e2213a Mon Sep 17 00:00:00 2001 From: bbaker Date: Tue, 18 Jun 2024 09:56:18 +1000 Subject: [PATCH 39/67] Small tweak allowed on Java 9 and later --- .../graphql/execution/reactive/NonBlockingMutexExecutor.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/graphql/execution/reactive/NonBlockingMutexExecutor.java b/src/main/java/graphql/execution/reactive/NonBlockingMutexExecutor.java index 38c6eb1238..f57ca7dbe4 100644 --- a/src/main/java/graphql/execution/reactive/NonBlockingMutexExecutor.java +++ b/src/main/java/graphql/execution/reactive/NonBlockingMutexExecutor.java @@ -78,7 +78,8 @@ private void runAll(RunNode next) { } else { //noinspection StatementWithEmptyBody while ((next = current.get()) == null) { - // Thread.onSpinWait(); in Java 9 + // hint to the CPU we are actively waiting + Thread.onSpinWait(); } } } From 6e2fd9343e7965267803d02cbc2c93dce9533169 Mon Sep 17 00:00:00 2001 From: bbaker Date: Tue, 18 Jun 2024 10:42:57 +1000 Subject: [PATCH 40/67] Small tweak allowed on Java 9 and later - redundant suppression --- .../graphql/execution/reactive/NonBlockingMutexExecutor.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/graphql/execution/reactive/NonBlockingMutexExecutor.java b/src/main/java/graphql/execution/reactive/NonBlockingMutexExecutor.java index f57ca7dbe4..a9da42d410 100644 --- a/src/main/java/graphql/execution/reactive/NonBlockingMutexExecutor.java +++ b/src/main/java/graphql/execution/reactive/NonBlockingMutexExecutor.java @@ -76,7 +76,6 @@ private void runAll(RunNode next) { if (last.compareAndSet(current, null)) { return; // end-of-queue: we're done. } else { - //noinspection StatementWithEmptyBody while ((next = current.get()) == null) { // hint to the CPU we are actively waiting Thread.onSpinWait(); From 1eb5551302c4c138dda9abbc6eef8061454a697d Mon Sep 17 00:00:00 2001 From: bbaker Date: Tue, 18 Jun 2024 18:58:30 +1000 Subject: [PATCH 41/67] Javadoc tweak --- .../execution/incremental/IncrementalCallState.java | 5 +++-- .../graphql/incremental/IncrementalExecutionResult.java | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/graphql/execution/incremental/IncrementalCallState.java b/src/main/java/graphql/execution/incremental/IncrementalCallState.java index 45ec6e0b3f..2f5c9742be 100644 --- a/src/main/java/graphql/execution/incremental/IncrementalCallState.java +++ b/src/main/java/graphql/execution/incremental/IncrementalCallState.java @@ -94,8 +94,9 @@ private Supplier> cre } /** - * When this is called the deferred execution will begin once the {@link Publisher} is subscribed - * to. + * This method will return a {@link Publisher} of deferred results. No field processing will be done + * until a {@link org.reactivestreams.Subscriber} is attached to this publisher. Once a {@link org.reactivestreams.Subscriber} + * is attached the deferred field result processing will be started and published as a series of events. * * @return the publisher of deferred results */ diff --git a/src/main/java/graphql/incremental/IncrementalExecutionResult.java b/src/main/java/graphql/incremental/IncrementalExecutionResult.java index b3dc1b929e..e261d7007f 100644 --- a/src/main/java/graphql/incremental/IncrementalExecutionResult.java +++ b/src/main/java/graphql/incremental/IncrementalExecutionResult.java @@ -86,6 +86,7 @@ public interface IncrementalExecutionResult extends ExecutionResult { /** * Indicates whether there are pending incremental data. + * * @return "true" if there are incremental data, "false" otherwise. */ boolean hasNext(); @@ -103,7 +104,11 @@ public interface IncrementalExecutionResult extends ExecutionResult { List getIncremental(); /** - * This {@link Publisher} will asynchronously emit events containing defer and/or stream payloads. + * This method will return a {@link Publisher} of deferred results. No field processing will be done + * until a {@link org.reactivestreams.Subscriber} is attached to this publisher. + *

+ * Once a {@link org.reactivestreams.Subscriber} is attached the deferred field result processing will be + * started and published as a series of events. * * @return a {@link Publisher} that clients can subscribe to receive incremental payloads. */ From 89c5555cda5585ef7ef087f8dc0826d1f08a41e7 Mon Sep 17 00:00:00 2001 From: bbaker Date: Tue, 18 Jun 2024 19:18:46 +1000 Subject: [PATCH 42/67] Added error handling just in case --- .../incremental/DeferExecutionSupportIntegrationTest.groovy | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy b/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy index 11e6e2d1f8..5dc9d579e9 100644 --- a/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy +++ b/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy @@ -1512,7 +1512,9 @@ class DeferExecutionSupportIntegrationTest extends Specification { deferredResultStream.subscribe(subscriber) Awaitility.await().untilTrue(subscriber.isDone()) - + if (subscriber.throwable != null) { + throw new RuntimeException(subscriber.throwable) + } return subscriber.getEvents() .collect { it.toSpecification() } } From 037e06be95534b716567f2f3a83fe2d6bd5c4d89 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Mon, 24 Jun 2024 13:15:36 +1000 Subject: [PATCH 43/67] Fix bug with error handling in the defer execution code --- .../graphql/execution/ExecutionStrategy.java | 43 ++++++----- .../java/graphql/execution/MergedField.java | 10 +++ .../execution/NonNullableFieldValidator.java | 14 +++- .../incremental/DeferredCallContext.java | 10 +-- .../NonNullableFieldValidatorTest.groovy | 8 +- ...eferExecutionSupportIntegrationTest.groovy | 75 +++++++++++++++++++ 6 files changed, 131 insertions(+), 29 deletions(-) diff --git a/src/main/java/graphql/execution/ExecutionStrategy.java b/src/main/java/graphql/execution/ExecutionStrategy.java index de1ae0e178..afeea41103 100644 --- a/src/main/java/graphql/execution/ExecutionStrategy.java +++ b/src/main/java/graphql/execution/ExecutionStrategy.java @@ -550,7 +550,9 @@ protected FetchedValue unboxPossibleDataFetcherResult(ExecutionContext execution if (result instanceof DataFetcherResult) { DataFetcherResult dataFetcherResult = (DataFetcherResult) result; - executionContext.addErrors(dataFetcherResult.getErrors()); + + addErrorsToRightContext(dataFetcherResult.getErrors(), parameters, executionContext); + addExtensionsIfPresent(executionContext, dataFetcherResult); Object localContext = dataFetcherResult.getLocalContext(); @@ -586,12 +588,6 @@ protected CompletableFuture handleFetchingException( .exception(e) .build(); - parameters.getDeferredCallContext().onFetchingException( - parameters.getPath(), - parameters.getField().getSingleField().getSourceLocation(), - e - ); - try { return asyncHandleException(dataFetcherExceptionHandler, handlerParameters); } catch (Exception handlerException) { @@ -714,9 +710,8 @@ protected FieldValueInfo completeValue(ExecutionContext executionContext, Execut private void handleUnresolvedTypeProblem(ExecutionContext context, ExecutionStrategyParameters parameters, UnresolvedTypeException e) { UnresolvedTypeError error = new UnresolvedTypeError(parameters.getPath(), parameters.getExecutionStepInfo(), e); - context.addError(error); - parameters.getDeferredCallContext().onError(error); + addErrorToRightContext(error, parameters, context); } /** @@ -745,7 +740,7 @@ private FieldValueInfo getFieldValueInfoForNull(ExecutionStrategyParameters para protected Object /* CompletableFuture | Object */ completeValueForNull(ExecutionStrategyParameters parameters) { try { - return parameters.getNonNullFieldValidator().validate(parameters.getPath(), null); + return parameters.getNonNullFieldValidator().validate(parameters, null); } catch (Exception e) { return Async.exceptionallyCompletedFuture(e); } @@ -764,7 +759,7 @@ private FieldValueInfo getFieldValueInfoForNull(ExecutionStrategyParameters para protected FieldValueInfo completeValueForList(ExecutionContext executionContext, ExecutionStrategyParameters parameters, Object result) { Iterable resultIterable = toIterable(executionContext, parameters, result); try { - resultIterable = parameters.getNonNullFieldValidator().validate(parameters.getPath(), resultIterable); + resultIterable = parameters.getNonNullFieldValidator().validate(parameters, resultIterable); } catch (NonNullableFieldWasNullException e) { return new FieldValueInfo(LIST, exceptionallyCompletedFuture(e)); } @@ -891,7 +886,7 @@ protected void handleValueException(CompletableFuture overallResult, Thro } try { - serialized = parameters.getNonNullFieldValidator().validate(parameters.getPath(), serialized); + serialized = parameters.getNonNullFieldValidator().validate(parameters, serialized); } catch (NonNullableFieldWasNullException e) { return exceptionallyCompletedFuture(e); } @@ -917,7 +912,7 @@ protected void handleValueException(CompletableFuture overallResult, Thro serialized = handleCoercionProblem(executionContext, parameters, e); } try { - serialized = parameters.getNonNullFieldValidator().validate(parameters.getPath(), serialized); + serialized = parameters.getNonNullFieldValidator().validate(parameters, serialized); } catch (NonNullableFieldWasNullException e) { return exceptionallyCompletedFuture(e); } @@ -971,9 +966,8 @@ protected void handleValueException(CompletableFuture overallResult, Thro @SuppressWarnings("SameReturnValue") private Object handleCoercionProblem(ExecutionContext context, ExecutionStrategyParameters parameters, CoercingSerializeException e) { SerializationError error = new SerializationError(parameters.getPath(), e); - context.addError(error); - parameters.getDeferredCallContext().onError(error); + addErrorToRightContext(error, parameters, context); return null; } @@ -997,9 +991,8 @@ protected Iterable toIterable(ExecutionContext context, ExecutionStrateg private void handleTypeMismatchProblem(ExecutionContext context, ExecutionStrategyParameters parameters) { TypeMismatchError error = new TypeMismatchError(parameters.getPath(), parameters.getExecutionStepInfo().getUnwrappedNonNullType()); - context.addError(error); - parameters.getDeferredCallContext().onError(error); + addErrorToRightContext(error, parameters, context); } /** @@ -1158,4 +1151,20 @@ private static Supplier> getArgumentV return argumentValues; } + // Errors that result from the execution of deferred fields are kept in the deferred context only. + private static void addErrorToRightContext(GraphQLError error, ExecutionStrategyParameters parameters, ExecutionContext executionContext) { + if (parameters.getField() != null && parameters.getField().isDeferred()) { + parameters.getDeferredCallContext().addError(error); + } else { + executionContext.addError(error); + } + } + + private static void addErrorsToRightContext(List errors, ExecutionStrategyParameters parameters, ExecutionContext executionContext) { + if (parameters.getField() != null && parameters.getField().isDeferred()) { + parameters.getDeferredCallContext().addErrors(errors); + } else { + executionContext.addErrors(errors); + } + } } diff --git a/src/main/java/graphql/execution/MergedField.java b/src/main/java/graphql/execution/MergedField.java index e66afb63f8..f1f807ea36 100644 --- a/src/main/java/graphql/execution/MergedField.java +++ b/src/main/java/graphql/execution/MergedField.java @@ -139,6 +139,16 @@ public List getDeferredExecutions() { return deferredExecutions; } + /** + * Returns true if this field is part of a deferred execution + * + * @return true if this field is part of a deferred execution + */ + @ExperimentalApi + public boolean isDeferred() { + return !deferredExecutions.isEmpty(); + } + public static Builder newMergedField() { return new Builder(); } diff --git a/src/main/java/graphql/execution/NonNullableFieldValidator.java b/src/main/java/graphql/execution/NonNullableFieldValidator.java index af26b69008..4372ff839b 100644 --- a/src/main/java/graphql/execution/NonNullableFieldValidator.java +++ b/src/main/java/graphql/execution/NonNullableFieldValidator.java @@ -1,6 +1,7 @@ package graphql.execution; +import graphql.GraphQLError; import graphql.Internal; /** @@ -23,7 +24,7 @@ public NonNullableFieldValidator(ExecutionContext executionContext, ExecutionSte /** * Called to check that a value is non null if the type requires it to be non null * - * @param path the path to this place + * @param parameters the execution strategy parameters * @param result the result to check * @param the type of the result * @@ -31,7 +32,7 @@ public NonNullableFieldValidator(ExecutionContext executionContext, ExecutionSte * * @throws NonNullableFieldWasNullException if the value is null but the type requires it to be non null */ - public T validate(ResultPath path, T result) throws NonNullableFieldWasNullException { + public T validate(ExecutionStrategyParameters parameters, T result) throws NonNullableFieldWasNullException { if (result == null) { if (executionStepInfo.isNonNullType()) { // see https://spec.graphql.org/October2021/#sec-Errors-and-Non-Nullability @@ -46,8 +47,15 @@ public T validate(ResultPath path, T result) throws NonNullableFieldWasNullE // // We will do this until the spec makes this more explicit. // + final ResultPath path = parameters.getPath(); + NonNullableFieldWasNullException nonNullException = new NonNullableFieldWasNullException(executionStepInfo, path); - executionContext.addError(new NonNullableFieldWasNullError(nonNullException), path); + final GraphQLError error = new NonNullableFieldWasNullError(nonNullException); + if(parameters.getField() != null && parameters.getField().isDeferred()) { + parameters.getDeferredCallContext().addError(error); + } else { + executionContext.addError(error, path); + } throw nonNullException; } } diff --git a/src/main/java/graphql/execution/incremental/DeferredCallContext.java b/src/main/java/graphql/execution/incremental/DeferredCallContext.java index 15f428966f..d7d494aace 100644 --- a/src/main/java/graphql/execution/incremental/DeferredCallContext.java +++ b/src/main/java/graphql/execution/incremental/DeferredCallContext.java @@ -1,10 +1,7 @@ package graphql.execution.incremental; -import graphql.ExceptionWhileDataFetching; import graphql.GraphQLError; import graphql.Internal; -import graphql.execution.ResultPath; -import graphql.language.SourceLocation; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; @@ -23,12 +20,11 @@ public class DeferredCallContext { private final List errors = new CopyOnWriteArrayList<>(); - public void onFetchingException(ResultPath path, SourceLocation sourceLocation, Throwable throwable) { - ExceptionWhileDataFetching error = new ExceptionWhileDataFetching(path, throwable, sourceLocation); - onError(error); + public void addErrors(List errors) { + this.errors.addAll(errors); } - public void onError(GraphQLError graphqlError) { + public void addError(GraphQLError graphqlError) { errors.add(graphqlError); } diff --git a/src/test/groovy/graphql/execution/NonNullableFieldValidatorTest.groovy b/src/test/groovy/graphql/execution/NonNullableFieldValidatorTest.groovy index 0a39bddd36..b288faff07 100644 --- a/src/test/groovy/graphql/execution/NonNullableFieldValidatorTest.groovy +++ b/src/test/groovy/graphql/execution/NonNullableFieldValidatorTest.groovy @@ -9,13 +9,17 @@ class NonNullableFieldValidatorTest extends Specification { ExecutionContext context = Mock(ExecutionContext) + def parameters = Mock(ExecutionStrategyParameters) { + getPath() >> ResultPath.rootPath() + } + def "non nullable field throws exception"() { ExecutionStepInfo typeInfo = ExecutionStepInfo.newExecutionStepInfo().type(nonNull(GraphQLString)).build() NonNullableFieldValidator validator = new NonNullableFieldValidator(context, typeInfo) when: - validator.validate(ResultPath.rootPath(), null) + validator.validate(parameters, null) then: thrown(NonNullableFieldWasNullException) @@ -28,7 +32,7 @@ class NonNullableFieldValidatorTest extends Specification { NonNullableFieldValidator validator = new NonNullableFieldValidator(context, typeInfo) when: - def result = validator.validate(ResultPath.rootPath(), null) + def result = validator.validate(parameters, null) then: result == null diff --git a/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy b/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy index 11e6e2d1f8..9a574208c9 100644 --- a/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy +++ b/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy @@ -6,7 +6,9 @@ import graphql.ExecutionInput import graphql.ExecutionResult import graphql.ExperimentalApi import graphql.GraphQL +import graphql.GraphqlErrorBuilder import graphql.TestUtil +import graphql.execution.DataFetcherResult import graphql.execution.pubsub.CapturingSubscriber import graphql.incremental.DelayedIncrementalPartialResult import graphql.incremental.IncrementalExecutionResult @@ -55,6 +57,7 @@ class DeferExecutionSupportIntegrationTest extends Specification { comments: [Comment] resolvesToNull: String dataFetcherError: String + dataAndError: String coercionError: Int typeMismatchError: [String] nonNullableError: String! @@ -128,6 +131,22 @@ class DeferExecutionSupportIntegrationTest extends Specification { } } + private static DataFetcher resolveWithDataAndError(Object data) { + return new DataFetcher() { + @Override + Object get(DataFetchingEnvironment environment) throws Exception { + return DataFetcherResult.newResult() + .data(data) + .error( + GraphqlErrorBuilder.newError() + .message("Bang!") + .build() + ) + .build() + } + } + } + void setup() { def runtimeWiring = RuntimeWiring.newRuntimeWiring() .type(newTypeWiring("Query") @@ -148,6 +167,7 @@ class DeferExecutionSupportIntegrationTest extends Specification { .type(newTypeWiring("Post").dataFetcher("wordCount", resolve(45999, 10, true))) .type(newTypeWiring("Post").dataFetcher("latestComment", resolve([title: "Comment title"], 10))) .type(newTypeWiring("Post").dataFetcher("dataFetcherError", resolveWithException())) + .type(newTypeWiring("Post").dataFetcher("dataAndError", resolveWithDataAndError("data"))) .type(newTypeWiring("Post").dataFetcher("coercionError", resolve("Not a number", 10))) .type(newTypeWiring("Post").dataFetcher("typeMismatchError", resolve([a: "A Map instead of a List"], 10))) .type(newTypeWiring("Post").dataFetcher("nonNullableError", resolve(null))) @@ -1158,6 +1178,61 @@ class DeferExecutionSupportIntegrationTest extends Specification { ] } + def "can handle data fetcher that returns both data and error"() { + def query = ''' + query { + post { + id + ... @defer { + dataAndError + } + ... @defer { + text + } + } + } + ''' + + when: + def initialResult = executeQuery(query) + + then: + initialResult.toSpecification() == [ + data : [post: [id: "1001"]], + hasNext: true + ] + + when: + def incrementalResults = getIncrementalResults(initialResult) + + then: + incrementalResults == [ + [ + hasNext : true, + incremental: [ + [ + path : ["post"], + data : [dataAndError: "data"], + errors: [[ + message : "Bang!", + locations : [], + extensions: [classification: "DataFetchingException"] + ]], + ], + ] + ], + [ + hasNext : false, + incremental: [ + [ + path: ["post"], + data: [text: "The full text"], + ] + ] + ] + ] + } + def "can handle UnresolvedTypeException"() { def query = """ query { From 0db3c7be864c1ef1492aecb043d495a294a2a789 Mon Sep 17 00:00:00 2001 From: Felipe Reis Date: Mon, 24 Jun 2024 14:27:46 +1000 Subject: [PATCH 44/67] Check if field is in a deferred context before adding error on the right context --- .../graphql/execution/ExecutionStrategy.java | 4 +- .../ExecutionStrategyParameters.java | 34 +++++++++++++-- .../execution/NonNullableFieldValidator.java | 2 +- ...eferExecutionSupportIntegrationTest.groovy | 43 +++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/main/java/graphql/execution/ExecutionStrategy.java b/src/main/java/graphql/execution/ExecutionStrategy.java index afeea41103..36c2e78956 100644 --- a/src/main/java/graphql/execution/ExecutionStrategy.java +++ b/src/main/java/graphql/execution/ExecutionStrategy.java @@ -1153,7 +1153,7 @@ private static Supplier> getArgumentV // Errors that result from the execution of deferred fields are kept in the deferred context only. private static void addErrorToRightContext(GraphQLError error, ExecutionStrategyParameters parameters, ExecutionContext executionContext) { - if (parameters.getField() != null && parameters.getField().isDeferred()) { + if (parameters.getDeferredCallContext() != null) { parameters.getDeferredCallContext().addError(error); } else { executionContext.addError(error); @@ -1161,7 +1161,7 @@ private static void addErrorToRightContext(GraphQLError error, ExecutionStrategy } private static void addErrorsToRightContext(List errors, ExecutionStrategyParameters parameters, ExecutionContext executionContext) { - if (parameters.getField() != null && parameters.getField().isDeferred()) { + if (parameters.getDeferredCallContext() != null) { parameters.getDeferredCallContext().addErrors(errors); } else { executionContext.addErrors(errors); diff --git a/src/main/java/graphql/execution/ExecutionStrategyParameters.java b/src/main/java/graphql/execution/ExecutionStrategyParameters.java index e1df7bb363..f74336bad1 100644 --- a/src/main/java/graphql/execution/ExecutionStrategyParameters.java +++ b/src/main/java/graphql/execution/ExecutionStrategyParameters.java @@ -2,6 +2,7 @@ import graphql.PublicApi; import graphql.execution.incremental.DeferredCallContext; +import org.jetbrains.annotations.Nullable; import java.util.function.Consumer; @@ -71,10 +72,40 @@ public ExecutionStrategyParameters getParent() { return parent; } + /** + * Returns the deferred call context if we're in the scope of a deferred call. + * A new DeferredCallContext is created for each @defer block, and is passed down to all fields within the deferred call. + * + *
+     *     query {
+     *        ... @defer {
+     *            field1 {        # new DeferredCallContext created here
+     *                field1a     # DeferredCallContext passed down to this field
+     *            }
+     *        }
+     *
+     *        ... @defer {
+     *            field2          # new DeferredCallContext created here
+     *        }
+     *     }
+     * 
+ * + * @return the deferred call context or null if we're not in the scope of a deferred call + */ + @Nullable public DeferredCallContext getDeferredCallContext() { return deferredCallContext; } + /** + * Returns true if we're in the scope of a deferred call. + * + * @return true if we're in the scope of a deferred call + */ + public boolean isInDeferredContext() { + return deferredCallContext != null; + } + /** * This returns the current field in its query representations. * @@ -187,9 +218,6 @@ public Builder deferredCallContext(DeferredCallContext deferredCallContext) { } public ExecutionStrategyParameters build() { - if (deferredCallContext == null) { - deferredCallContext = new DeferredCallContext(); - } return new ExecutionStrategyParameters(executionStepInfo, source, localContext, fields, nonNullableFieldValidator, path, currentField, parent, deferredCallContext); } } diff --git a/src/main/java/graphql/execution/NonNullableFieldValidator.java b/src/main/java/graphql/execution/NonNullableFieldValidator.java index 4372ff839b..3241fa247a 100644 --- a/src/main/java/graphql/execution/NonNullableFieldValidator.java +++ b/src/main/java/graphql/execution/NonNullableFieldValidator.java @@ -51,7 +51,7 @@ public T validate(ExecutionStrategyParameters parameters, T result) throws N NonNullableFieldWasNullException nonNullException = new NonNullableFieldWasNullException(executionStepInfo, path); final GraphQLError error = new NonNullableFieldWasNullError(nonNullException); - if(parameters.getField() != null && parameters.getField().isDeferred()) { + if(parameters.getDeferredCallContext() != null) { parameters.getDeferredCallContext().addError(error); } else { executionContext.addError(error, path); diff --git a/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy b/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy index 9a574208c9..a1d6d0a6e1 100644 --- a/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy +++ b/src/test/groovy/graphql/execution/incremental/DeferExecutionSupportIntegrationTest.groovy @@ -1178,6 +1178,49 @@ class DeferExecutionSupportIntegrationTest extends Specification { ] } + def "can handle data fetcher that returns both data and error on nested field"() { + def query = ''' + query { + hello + ... @defer { + post { + dataAndError + } + } + } + ''' + + when: + def initialResult = executeQuery(query) + + then: + initialResult.toSpecification() == [ + data : [hello: "world"], + hasNext: true + ] + + when: + def incrementalResults = getIncrementalResults(initialResult) + + then: + incrementalResults == [ + [ + hasNext : false, + incremental: [ + [ + path : [], + data : [post: [dataAndError: "data"]], + errors: [[ + message : "Bang!", + locations : [], + extensions: [classification: "DataFetchingException"] + ]], + ], + ] + ], + ] + } + def "can handle data fetcher that returns both data and error"() { def query = ''' query { From 76f681deb573740deb0b96452cbf2245c1798872 Mon Sep 17 00:00:00 2001 From: Franklin Wang Date: Mon, 24 Jun 2024 14:59:48 +1000 Subject: [PATCH 45/67] Fix builder return type and expose generic toSpecification --- .../incremental/IncrementalPayload.java | 19 ++--- .../incremental/DeferPayloadTest.groovy | 84 ++++++++++++++++++- .../incremental/StreamPayloadTest.groovy | 78 +++++++++++++++++ 3 files changed, 168 insertions(+), 13 deletions(-) diff --git a/src/main/java/graphql/incremental/IncrementalPayload.java b/src/main/java/graphql/incremental/IncrementalPayload.java index 76b1fbb405..78f918fcf2 100644 --- a/src/main/java/graphql/incremental/IncrementalPayload.java +++ b/src/main/java/graphql/incremental/IncrementalPayload.java @@ -1,6 +1,5 @@ package graphql.incremental; -import graphql.ExecutionResult; import graphql.ExperimentalApi; import graphql.GraphQLError; import graphql.execution.ResultPath; @@ -61,7 +60,7 @@ public Map getExtensions() { return this.extensions; } - protected Map toSpecification() { + public Map toSpecification() { Map result = new LinkedHashMap<>(); result.put("path", path); @@ -122,25 +121,25 @@ public T errors(List errors) { return (T) this; } - public Builder addErrors(List errors) { + public T addErrors(List errors) { this.errors.addAll(errors); - return this; + return (T) this; } - public Builder addError(GraphQLError error) { + public T addError(GraphQLError error) { this.errors.add(error); - return this; + return (T) this; } - public Builder extensions(Map extensions) { + public T extensions(Map extensions) { this.extensions = extensions; - return this; + return (T) this; } - public Builder addExtension(String key, Object value) { + public T addExtension(String key, Object value) { this.extensions = (this.extensions == null ? new LinkedHashMap<>() : this.extensions); this.extensions.put(key, value); - return this; + return (T) this; } } } diff --git a/src/test/groovy/graphql/incremental/DeferPayloadTest.groovy b/src/test/groovy/graphql/incremental/DeferPayloadTest.groovy index 90d6a0899c..a37fa3ca71 100644 --- a/src/test/groovy/graphql/incremental/DeferPayloadTest.groovy +++ b/src/test/groovy/graphql/incremental/DeferPayloadTest.groovy @@ -1,6 +1,7 @@ package graphql.incremental - +import graphql.GraphqlErrorBuilder +import graphql.execution.ResultPath import spock.lang.Specification class DeferPayloadTest extends Specification { @@ -14,8 +15,85 @@ class DeferPayloadTest extends Specification { then: spec == [ - data : null, - path : null, + data: null, + path: null, + ] + } + + def "can construct an instance using builder"() { + def payload = DeferPayload.newDeferredItem() + .data("twow is that a bee") + .path(["hello"]) + .errors([]) + .addError(GraphqlErrorBuilder.newError() + .message("wow") + .build()) + .addErrors([ + GraphqlErrorBuilder.newError() + .message("yep") + .build(), + ]) + .extensions([echo: "Hello world"]) + .build() + + when: + def serialized = payload.toSpecification() + + then: + serialized == [ + data : "twow is that a bee", + path : ["hello"], + errors : [ + [ + message : "wow", + locations : [], + extensions: [classification: "DataFetchingException"], + ], + [ + message : "yep", + locations : [], + extensions: [classification: "DataFetchingException"], + ], + ], + extensions: [ + echo: "Hello world", + ], + ] + } + + def "errors replaces existing errors"() { + def payload = DeferPayload.newDeferredItem() + .data("twow is that a bee") + .path(ResultPath.fromList(["test", "echo"])) + .addError(GraphqlErrorBuilder.newError() + .message("wow") + .build()) + .addErrors([]) + .errors([ + GraphqlErrorBuilder.newError() + .message("yep") + .build(), + ]) + .extensions([echo: "Hello world"]) + .build() + + when: + def serialized = payload.toSpecification() + + then: + serialized == [ + data : "twow is that a bee", + errors : [ + [ + message : "yep", + locations : [], + extensions: [classification: "DataFetchingException"], + ], + ], + extensions: [ + echo: "Hello world", + ], + path : ["test", "echo"], ] } } diff --git a/src/test/groovy/graphql/incremental/StreamPayloadTest.groovy b/src/test/groovy/graphql/incremental/StreamPayloadTest.groovy index bee0d88631..56a2a5b7f1 100644 --- a/src/test/groovy/graphql/incremental/StreamPayloadTest.groovy +++ b/src/test/groovy/graphql/incremental/StreamPayloadTest.groovy @@ -1,5 +1,7 @@ package graphql.incremental +import graphql.GraphqlErrorBuilder +import graphql.execution.ResultPath import spock.lang.Specification class StreamPayloadTest extends Specification { @@ -15,6 +17,82 @@ class StreamPayloadTest extends Specification { spec == [ items: null, path : null, + ] + } + def "can construct an instance using builder"() { + def payload = StreamPayload.newStreamedItem() + .items(["twow is that a bee"]) + .path(["hello"]) + .errors([]) + .addError(GraphqlErrorBuilder.newError() + .message("wow") + .build()) + .addErrors([ + GraphqlErrorBuilder.newError() + .message("yep") + .build(), + ]) + .extensions([echo: "Hello world"]) + .build() + + when: + def serialized = payload.toSpecification() + + then: + serialized == [ + items : ["twow is that a bee"], + path : ["hello"], + errors : [ + [ + message : "wow", + locations : [], + extensions: [classification: "DataFetchingException"], + ], + [ + message : "yep", + locations : [], + extensions: [classification: "DataFetchingException"], + ], + ], + extensions: [ + echo: "Hello world", + ], + ] + } + + def "errors replaces existing errors"() { + def payload = StreamPayload.newStreamedItem() + .items(["twow is that a bee"]) + .path(ResultPath.fromList(["test", "echo"])) + .addError(GraphqlErrorBuilder.newError() + .message("wow") + .build()) + .addErrors([]) + .errors([ + GraphqlErrorBuilder.newError() + .message("yep") + .build(), + ]) + .extensions([echo: "Hello world"]) + .build() + + when: + def serialized = payload.toSpecification() + + then: + serialized == [ + items : ["twow is that a bee"], + errors : [ + [ + message : "yep", + locations : [], + extensions: [classification: "DataFetchingException"], + ], + ], + extensions: [ + echo: "Hello world", + ], + path : ["test", "echo"], ] } } From f22bbb672f6cfb4dbd0214eb6e34e949a6f04984 Mon Sep 17 00:00:00 2001 From: Franklin Wang Date: Mon, 24 Jun 2024 15:03:21 +1000 Subject: [PATCH 46/67] Fix formatting --- src/test/groovy/graphql/incremental/StreamPayloadTest.groovy | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/groovy/graphql/incremental/StreamPayloadTest.groovy b/src/test/groovy/graphql/incremental/StreamPayloadTest.groovy index 56a2a5b7f1..7386cb8efd 100644 --- a/src/test/groovy/graphql/incremental/StreamPayloadTest.groovy +++ b/src/test/groovy/graphql/incremental/StreamPayloadTest.groovy @@ -17,8 +17,9 @@ class StreamPayloadTest extends Specification { spec == [ items: null, path : null, - ] + ] } + def "can construct an instance using builder"() { def payload = StreamPayload.newStreamedItem() .items(["twow is that a bee"]) From f3ffa9afff60d9d8d293f319d43a0334018e4361 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jun 2024 16:17:26 +0000 Subject: [PATCH 47/67] Bump io.projectreactor:reactor-core from 3.6.5 to 3.6.7 Bumps [io.projectreactor:reactor-core](https://github.com/reactor/reactor-core) from 3.6.5 to 3.6.7. - [Release notes](https://github.com/reactor/reactor-core/releases) - [Commits](https://github.com/reactor/reactor-core/compare/v3.6.5...v3.6.7) --- updated-dependencies: - dependency-name: io.projectreactor:reactor-core dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 51f04cb3ea..905a95614b 100644 --- a/build.gradle +++ b/build.gradle @@ -117,7 +117,7 @@ dependencies { testImplementation 'org.reactivestreams:reactive-streams-tck:' + reactiveStreamsVersion testImplementation "io.reactivex.rxjava2:rxjava:2.2.21" - testImplementation "io.projectreactor:reactor-core:3.6.5" + testImplementation "io.projectreactor:reactor-core:3.6.7" testImplementation 'org.testng:testng:7.10.2' // use for reactive streams test inheritance From c0b518af9d0bb7086e923d90579b9120e97b2ca1 Mon Sep 17 00:00:00 2001 From: dondonz <13839920+dondonz@users.noreply.github.com> Date: Tue, 25 Jun 2024 19:47:40 +1000 Subject: [PATCH 48/67] Use jetbrains nullability annotations --- src/main/java/graphql/execution/MergedField.java | 2 +- .../java/graphql/execution/incremental/DeferredExecution.java | 3 +-- src/main/java/graphql/incremental/DeferPayload.java | 2 +- .../graphql/incremental/DelayedIncrementalPartialResult.java | 2 +- .../java/graphql/incremental/IncrementalExecutionResult.java | 2 +- .../graphql/incremental/IncrementalExecutionResultImpl.java | 2 +- src/main/java/graphql/incremental/IncrementalPayload.java | 2 +- src/main/java/graphql/incremental/StreamPayload.java | 2 +- .../normalized/incremental/NormalizedDeferredExecution.java | 2 +- src/test/java/reproductions/SubscriptionReproduction.java | 4 ++-- 10 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/main/java/graphql/execution/MergedField.java b/src/main/java/graphql/execution/MergedField.java index e66afb63f8..2100c966f5 100644 --- a/src/main/java/graphql/execution/MergedField.java +++ b/src/main/java/graphql/execution/MergedField.java @@ -6,8 +6,8 @@ import graphql.execution.incremental.DeferredExecution; import graphql.language.Argument; import graphql.language.Field; +import org.jetbrains.annotations.Nullable; -import javax.annotation.Nullable; import java.util.List; import java.util.Objects; import java.util.function.Consumer; diff --git a/src/main/java/graphql/execution/incremental/DeferredExecution.java b/src/main/java/graphql/execution/incremental/DeferredExecution.java index 3f14f5922e..6bc05973c2 100644 --- a/src/main/java/graphql/execution/incremental/DeferredExecution.java +++ b/src/main/java/graphql/execution/incremental/DeferredExecution.java @@ -2,8 +2,7 @@ import graphql.ExperimentalApi; import graphql.normalized.incremental.NormalizedDeferredExecution; - -import javax.annotation.Nullable; +import org.jetbrains.annotations.Nullable; /** * Represents details about the defer execution that can be associated with a {@link graphql.execution.MergedField}. diff --git a/src/main/java/graphql/incremental/DeferPayload.java b/src/main/java/graphql/incremental/DeferPayload.java index 8d6047de3a..fcfb06c0c4 100644 --- a/src/main/java/graphql/incremental/DeferPayload.java +++ b/src/main/java/graphql/incremental/DeferPayload.java @@ -3,8 +3,8 @@ import graphql.ExecutionResult; import graphql.ExperimentalApi; import graphql.GraphQLError; +import org.jetbrains.annotations.Nullable; -import javax.annotation.Nullable; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; diff --git a/src/main/java/graphql/incremental/DelayedIncrementalPartialResult.java b/src/main/java/graphql/incremental/DelayedIncrementalPartialResult.java index 706944e528..e1edaafe03 100644 --- a/src/main/java/graphql/incremental/DelayedIncrementalPartialResult.java +++ b/src/main/java/graphql/incremental/DelayedIncrementalPartialResult.java @@ -1,8 +1,8 @@ package graphql.incremental; import graphql.ExperimentalApi; +import org.jetbrains.annotations.Nullable; -import javax.annotation.Nullable; import java.util.List; import java.util.Map; diff --git a/src/main/java/graphql/incremental/IncrementalExecutionResult.java b/src/main/java/graphql/incremental/IncrementalExecutionResult.java index e261d7007f..a0bc40c32f 100644 --- a/src/main/java/graphql/incremental/IncrementalExecutionResult.java +++ b/src/main/java/graphql/incremental/IncrementalExecutionResult.java @@ -2,9 +2,9 @@ import graphql.ExecutionResult; import graphql.ExperimentalApi; +import org.jetbrains.annotations.Nullable; import org.reactivestreams.Publisher; -import javax.annotation.Nullable; import java.util.List; /** diff --git a/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java b/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java index c7456d0e53..77b05c6fe5 100644 --- a/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java +++ b/src/main/java/graphql/incremental/IncrementalExecutionResultImpl.java @@ -3,9 +3,9 @@ import graphql.ExecutionResult; import graphql.ExecutionResultImpl; import graphql.ExperimentalApi; +import org.jetbrains.annotations.Nullable; import org.reactivestreams.Publisher; -import javax.annotation.Nullable; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; diff --git a/src/main/java/graphql/incremental/IncrementalPayload.java b/src/main/java/graphql/incremental/IncrementalPayload.java index 78f918fcf2..3ca39e1839 100644 --- a/src/main/java/graphql/incremental/IncrementalPayload.java +++ b/src/main/java/graphql/incremental/IncrementalPayload.java @@ -3,8 +3,8 @@ import graphql.ExperimentalApi; import graphql.GraphQLError; import graphql.execution.ResultPath; +import org.jetbrains.annotations.Nullable; -import javax.annotation.Nullable; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; diff --git a/src/main/java/graphql/incremental/StreamPayload.java b/src/main/java/graphql/incremental/StreamPayload.java index 88e1e7b543..299f5ac468 100644 --- a/src/main/java/graphql/incremental/StreamPayload.java +++ b/src/main/java/graphql/incremental/StreamPayload.java @@ -2,8 +2,8 @@ import graphql.ExperimentalApi; import graphql.GraphQLError; +import org.jetbrains.annotations.Nullable; -import javax.annotation.Nullable; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; diff --git a/src/main/java/graphql/normalized/incremental/NormalizedDeferredExecution.java b/src/main/java/graphql/normalized/incremental/NormalizedDeferredExecution.java index a3f789e063..2f13c4c88e 100644 --- a/src/main/java/graphql/normalized/incremental/NormalizedDeferredExecution.java +++ b/src/main/java/graphql/normalized/incremental/NormalizedDeferredExecution.java @@ -2,8 +2,8 @@ import graphql.ExperimentalApi; import graphql.schema.GraphQLObjectType; +import org.jetbrains.annotations.Nullable; -import javax.annotation.Nullable; import java.util.Set; /** diff --git a/src/test/java/reproductions/SubscriptionReproduction.java b/src/test/java/reproductions/SubscriptionReproduction.java index ad7d09f966..07be4d000b 100644 --- a/src/test/java/reproductions/SubscriptionReproduction.java +++ b/src/test/java/reproductions/SubscriptionReproduction.java @@ -9,6 +9,7 @@ import graphql.schema.idl.RuntimeWiring; import graphql.schema.idl.SchemaGenerator; import graphql.schema.idl.SchemaParser; +import org.jetbrains.annotations.NotNull; import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; @@ -16,7 +17,6 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; -import javax.annotation.Nonnull; import java.util.Map; import java.util.Random; import java.util.concurrent.CompletableFuture; @@ -134,7 +134,7 @@ private static Integer getCounter(Map video) { return counter; } - private @Nonnull Object mkValue(Integer counter) { + private @NotNull Object mkValue(Integer counter) { // name and isFavorite are future values via DFs return Map.of( "counter", counter, From 4e4932f4af820ea8568b57bb1912db9b055d7e25 Mon Sep 17 00:00:00 2001 From: dondonz <13839920+dondonz@users.noreply.github.com> Date: Wed, 26 Jun 2024 16:54:58 +1000 Subject: [PATCH 49/67] Add missing NamedNode interface --- src/main/java/graphql/language/OperationDefinition.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/graphql/language/OperationDefinition.java b/src/main/java/graphql/language/OperationDefinition.java index 1a7197c88f..b60aac3a11 100644 --- a/src/main/java/graphql/language/OperationDefinition.java +++ b/src/main/java/graphql/language/OperationDefinition.java @@ -21,7 +21,7 @@ import static graphql.language.NodeChildrenContainer.newNodeChildrenContainer; @PublicApi -public class OperationDefinition extends AbstractNode implements Definition, SelectionSetContainer, DirectivesContainer { +public class OperationDefinition extends AbstractNode implements Definition, SelectionSetContainer, DirectivesContainer, NamedNode { public enum Operation { QUERY, MUTATION, SUBSCRIPTION From 98250a245837b3218bacea1b1e6611f3bb3829db Mon Sep 17 00:00:00 2001 From: bbaker Date: Sun, 30 Jun 2024 15:21:57 +1000 Subject: [PATCH 50/67] This introduces a Traversal Options to allow skipping the coercing of field arguments --- .../analysis/NodeVisitorWithTypeTracking.java | 29 ++++++--- .../graphql/analysis/QueryTransformer.java | 35 +++++++++-- .../analysis/QueryTraversalOptions.java | 31 ++++++++++ .../java/graphql/analysis/QueryTraverser.java | 61 ++++++++++++++++--- .../analysis/QueryTransformerTest.groovy | 57 +++++++++++++++++ .../analysis/QueryTraverserTest.groovy | 55 +++++++++++++++++ 6 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 src/main/java/graphql/analysis/QueryTraversalOptions.java diff --git a/src/main/java/graphql/analysis/NodeVisitorWithTypeTracking.java b/src/main/java/graphql/analysis/NodeVisitorWithTypeTracking.java index 2a2b237aeb..c2f0bebd3f 100644 --- a/src/main/java/graphql/analysis/NodeVisitorWithTypeTracking.java +++ b/src/main/java/graphql/analysis/NodeVisitorWithTypeTracking.java @@ -30,6 +30,7 @@ import graphql.util.TraversalControl; import graphql.util.TraverserContext; +import java.util.Collections; import java.util.Locale; import java.util.Map; @@ -50,13 +51,20 @@ public class NodeVisitorWithTypeTracking extends NodeVisitorStub { private final GraphQLSchema schema; private final Map fragmentsByName; private final ConditionalNodes conditionalNodes = new ConditionalNodes(); - - public NodeVisitorWithTypeTracking(QueryVisitor preOrderCallback, QueryVisitor postOrderCallback, Map variables, GraphQLSchema schema, Map fragmentsByName) { + private final QueryTraversalOptions options; + + public NodeVisitorWithTypeTracking(QueryVisitor preOrderCallback, + QueryVisitor postOrderCallback, + Map variables, + GraphQLSchema schema, + Map fragmentsByName, + QueryTraversalOptions options) { this.preOrderCallback = preOrderCallback; this.postOrderCallback = postOrderCallback; this.variables = variables; this.schema = schema; this.fragmentsByName = fragmentsByName; + this.options = options; } @Override @@ -156,12 +164,17 @@ public TraversalControl visitField(Field field, TraverserContext context) boolean isTypeNameIntrospectionField = fieldDefinition == schema.getIntrospectionTypenameFieldDefinition(); GraphQLFieldsContainer fieldsContainer = !isTypeNameIntrospectionField ? (GraphQLFieldsContainer) unwrapAll(parentEnv.getOutputType()) : null; GraphQLCodeRegistry codeRegistry = schema.getCodeRegistry(); - Map argumentValues = ValuesResolver.getArgumentValues(codeRegistry, - fieldDefinition.getArguments(), - field.getArguments(), - CoercedVariables.of(variables), - GraphQLContext.getDefault(), - Locale.getDefault()); + Map argumentValues; + if (options.isCoerceFieldArguments()) { + argumentValues = ValuesResolver.getArgumentValues(codeRegistry, + fieldDefinition.getArguments(), + field.getArguments(), + CoercedVariables.of(variables), + GraphQLContext.getDefault(), + Locale.getDefault()); + } else { + argumentValues = Collections.emptyMap(); + } QueryVisitorFieldEnvironment environment = new QueryVisitorFieldEnvironmentImpl(isTypeNameIntrospectionField, field, fieldDefinition, diff --git a/src/main/java/graphql/analysis/QueryTransformer.java b/src/main/java/graphql/analysis/QueryTransformer.java index 9c45902dae..eed41818e9 100644 --- a/src/main/java/graphql/analysis/QueryTransformer.java +++ b/src/main/java/graphql/analysis/QueryTransformer.java @@ -36,20 +36,22 @@ public class QueryTransformer { private final GraphQLSchema schema; private final Map fragmentsByName; private final Map variables; - private final GraphQLCompositeType rootParentType; + private final QueryTraversalOptions options; private QueryTransformer(GraphQLSchema schema, Node root, GraphQLCompositeType rootParentType, Map fragmentsByName, - Map variables) { + Map variables, + QueryTraversalOptions options) { this.schema = assertNotNull(schema, () -> "schema can't be null"); this.variables = assertNotNull(variables, () -> "variables can't be null"); this.root = assertNotNull(root, () -> "root can't be null"); this.rootParentType = assertNotNull(rootParentType); this.fragmentsByName = assertNotNull(fragmentsByName, () -> "fragmentsByName can't be null"); + this.options = assertNotNull(options, () -> "options can't be null"); } /** @@ -65,12 +67,17 @@ private QueryTransformer(GraphQLSchema schema, */ public Node transform(QueryVisitor queryVisitor) { QueryVisitor noOp = new QueryVisitorStub(); - NodeVisitorWithTypeTracking nodeVisitor = new NodeVisitorWithTypeTracking(queryVisitor, noOp, variables, schema, fragmentsByName); + NodeVisitorWithTypeTracking nodeVisitor = new NodeVisitorWithTypeTracking(queryVisitor, + noOp, + variables, + schema, + fragmentsByName, + options); Map, Object> rootVars = new LinkedHashMap<>(); rootVars.put(QueryTraversalContext.class, new QueryTraversalContext(rootParentType, null, null, GraphQLContext.getDefault())); - TraverserVisitor nodeTraverserVisitor = new TraverserVisitor() { + TraverserVisitor nodeTraverserVisitor = new TraverserVisitor<>() { @Override public TraversalControl enter(TraverserContext context) { @@ -98,6 +105,7 @@ public static class Builder { private Node root; private GraphQLCompositeType rootParentType; private Map fragmentsByName; + private QueryTraversalOptions options = QueryTraversalOptions.defaultOptions(); /** @@ -160,8 +168,25 @@ public Builder fragmentsByName(Map fragmentsByName) return this; } + /** + * Sets the options to use while traversing + * + * @param options the options to use + * @return this builder + */ + public Builder options(QueryTraversalOptions options) { + this.options = assertNotNull(options, () -> "options can't be null"); + return this; + } + public QueryTransformer build() { - return new QueryTransformer(schema, root, rootParentType, fragmentsByName, variables); + return new QueryTransformer( + schema, + root, + rootParentType, + fragmentsByName, + variables, + options); } } } diff --git a/src/main/java/graphql/analysis/QueryTraversalOptions.java b/src/main/java/graphql/analysis/QueryTraversalOptions.java new file mode 100644 index 0000000000..7ce73f05ce --- /dev/null +++ b/src/main/java/graphql/analysis/QueryTraversalOptions.java @@ -0,0 +1,31 @@ +package graphql.analysis; + +import graphql.PublicApi; + +/** + * This options object controls how {@link QueryTraverser} works + */ +@PublicApi +public class QueryTraversalOptions { + + private final boolean coerceFieldArguments; + + private QueryTraversalOptions(boolean coerceFieldArguments) { + this.coerceFieldArguments = coerceFieldArguments; + } + + /** + * @return true if field arguments should be coerced. This is true by default. + */ + public boolean isCoerceFieldArguments() { + return coerceFieldArguments; + } + + public static QueryTraversalOptions defaultOptions() { + return new QueryTraversalOptions(true); + } + + public QueryTraversalOptions coerceFieldArguments(boolean coerceFieldArguments) { + return new QueryTraversalOptions(coerceFieldArguments); + } +} diff --git a/src/main/java/graphql/analysis/QueryTraverser.java b/src/main/java/graphql/analysis/QueryTraverser.java index 0ec067595b..2f543e5b43 100644 --- a/src/main/java/graphql/analysis/QueryTraverser.java +++ b/src/main/java/graphql/analysis/QueryTraverser.java @@ -49,23 +49,29 @@ public class QueryTraverser { private CoercedVariables coercedVariables; private final GraphQLCompositeType rootParentType; + private final QueryTraversalOptions options; private QueryTraverser(GraphQLSchema schema, Document document, String operation, - CoercedVariables coercedVariables) { + CoercedVariables coercedVariables, + QueryTraversalOptions options + ) { this.schema = schema; NodeUtil.GetOperationResult getOperationResult = NodeUtil.getOperation(document, operation); this.fragmentsByName = getOperationResult.fragmentsByName; this.roots = singletonList(getOperationResult.operationDefinition); this.rootParentType = getRootTypeFromOperation(getOperationResult.operationDefinition); this.coercedVariables = coercedVariables; + this.options = options; } private QueryTraverser(GraphQLSchema schema, Document document, String operation, - RawVariables rawVariables) { + RawVariables rawVariables, + QueryTraversalOptions options + ) { this.schema = schema; NodeUtil.GetOperationResult getOperationResult = NodeUtil.getOperation(document, operation); List variableDefinitions = getOperationResult.operationDefinition.getVariableDefinitions(); @@ -73,18 +79,22 @@ private QueryTraverser(GraphQLSchema schema, this.roots = singletonList(getOperationResult.operationDefinition); this.rootParentType = getRootTypeFromOperation(getOperationResult.operationDefinition); this.coercedVariables = ValuesResolver.coerceVariableValues(schema, variableDefinitions, rawVariables, GraphQLContext.getDefault(), Locale.getDefault()); + this.options = options; } private QueryTraverser(GraphQLSchema schema, Node root, GraphQLCompositeType rootParentType, Map fragmentsByName, - CoercedVariables coercedVariables) { + CoercedVariables coercedVariables, + QueryTraversalOptions options + ) { this.schema = schema; this.roots = Collections.singleton(root); this.rootParentType = rootParentType; this.fragmentsByName = fragmentsByName; this.coercedVariables = coercedVariables; + this.options = options; } public Object visitDepthFirst(QueryVisitor queryVisitor) { @@ -191,7 +201,12 @@ private Object visitImpl(QueryVisitor visitFieldCallback, Boolean preOrder) { } NodeTraverser nodeTraverser = new NodeTraverser(rootVars, this::childrenOf); - NodeVisitorWithTypeTracking nodeVisitorWithTypeTracking = new NodeVisitorWithTypeTracking(preOrderCallback, postOrderCallback, coercedVariables.toMap(), schema, fragmentsByName); + NodeVisitorWithTypeTracking nodeVisitorWithTypeTracking = new NodeVisitorWithTypeTracking(preOrderCallback, + postOrderCallback, + coercedVariables.toMap(), + schema, + fragmentsByName, + options); return nodeTraverser.depthFirst(nodeVisitorWithTypeTracking, roots); } @@ -210,6 +225,7 @@ public static class Builder { private Node root; private GraphQLCompositeType rootParentType; private Map fragmentsByName; + private QueryTraversalOptions options = QueryTraversalOptions.defaultOptions(); /** @@ -313,6 +329,17 @@ public Builder fragmentsByName(Map fragmentsByName) return this; } + /** + * Sets the options to use while traversing + * + * @param options the options to use + * @return this builder + */ + public Builder options(QueryTraversalOptions options) { + this.options = assertNotNull(options, () -> "options can't be null"); + return this; + } + /** * @return a built {@link QueryTraverser} object */ @@ -320,17 +347,35 @@ public QueryTraverser build() { checkState(); if (document != null) { if (rawVariables != null) { - return new QueryTraverser(schema, document, operation, rawVariables); + return new QueryTraverser(schema, + document, + operation, + rawVariables, + options); } - return new QueryTraverser(schema, document, operation, coercedVariables); + return new QueryTraverser(schema, + document, + operation, + coercedVariables, + options); } else { if (rawVariables != null) { // When traversing with an arbitrary root, there is no variable definition context available // Thus, the variables must have already been coerced // Retaining this builder for backwards compatibility - return new QueryTraverser(schema, root, rootParentType, fragmentsByName, CoercedVariables.of(rawVariables.toMap())); + return new QueryTraverser(schema, + root, + rootParentType, + fragmentsByName, + CoercedVariables.of(rawVariables.toMap()), + options); } - return new QueryTraverser(schema, root, rootParentType, fragmentsByName, coercedVariables); + return new QueryTraverser(schema, + root, + rootParentType, + fragmentsByName, + coercedVariables, + options); } } diff --git a/src/test/groovy/graphql/analysis/QueryTransformerTest.groovy b/src/test/groovy/graphql/analysis/QueryTransformerTest.groovy index 9425a32162..2ce312c7ce 100644 --- a/src/test/groovy/graphql/analysis/QueryTransformerTest.groovy +++ b/src/test/groovy/graphql/analysis/QueryTransformerTest.groovy @@ -1,6 +1,7 @@ package graphql.analysis import graphql.TestUtil +import graphql.execution.CoercedVariables import graphql.language.Document import graphql.language.Field import graphql.language.NodeUtil @@ -448,4 +449,60 @@ class QueryTransformerTest extends Specification { printAstCompact(newNode) == "{__typename ...on A{aX}...on B{b}}" } + + def "can coerce field arguments or not"() { + def sdl = """ + input Test{ x: String!} + type Query{ testInput(input: Test!): String} + type Mutation{ testInput(input: Test!): String} + """ + + def schema = TestUtil.schema(sdl) + + def query = createQuery(''' + mutation a($test: Test!) { + testInput(input: $test) + }''') + + + def fieldArgMap = [:] + def queryVisitorStub = new QueryVisitorStub() { + @Override + void visitField(QueryVisitorFieldEnvironment queryVisitorFieldEnvironment) { + super.visitField(queryVisitorFieldEnvironment) + fieldArgMap = queryVisitorFieldEnvironment.getArguments() + } + } + + when: + QueryTraverser.newQueryTraverser() + .schema(schema) + .document(query) + .coercedVariables(CoercedVariables.of([test: [x: "X"]])) + .build() + .visitPreOrder(queryVisitorStub) + + then: + + fieldArgMap == [input: [x:"X"]] + + when: + fieldArgMap = null + + + def options = QueryTraversalOptions.defaultOptions() + .coerceFieldArguments(false) + QueryTraverser.newQueryTraverser() + .schema(schema) + .document(query) + .coercedVariables(CoercedVariables.of([test: [x: "X"]])) + .options(options) + .build() + .visitPreOrder(queryVisitorStub) + + + then: + fieldArgMap == [:] // empty map + } + } diff --git a/src/test/groovy/graphql/analysis/QueryTraverserTest.groovy b/src/test/groovy/graphql/analysis/QueryTraverserTest.groovy index 03a40919ca..dbe0b33384 100644 --- a/src/test/groovy/graphql/analysis/QueryTraverserTest.groovy +++ b/src/test/groovy/graphql/analysis/QueryTraverserTest.groovy @@ -1907,4 +1907,59 @@ class QueryTraverserTest extends Specification { then: "it should not be visited" 0 * visitor.visitField(_) } + + def "can coerce field arguments or not"() { + def sdl = """ + input Test{ x: String!} + type Query{ testInput(input: Test!): String} + type Mutation{ testInput(input: Test!): String} + """ + + def schema = TestUtil.schema(sdl) + + def query = createQuery(''' + mutation a($test: Test!) { + testInput(input: $test) + }''') + + + def fieldArgMap = [:] + def queryVisitorStub = new QueryVisitorStub() { + @Override + void visitField(QueryVisitorFieldEnvironment queryVisitorFieldEnvironment) { + super.visitField(queryVisitorFieldEnvironment) + fieldArgMap = queryVisitorFieldEnvironment.getArguments() + } + } + + when: + QueryTraverser.newQueryTraverser() + .schema(schema) + .document(query) + .coercedVariables(CoercedVariables.of([test: [x: "X"]])) + .build() + .visitPreOrder(queryVisitorStub) + + then: + + fieldArgMap == [input: [x:"X"]] + + when: + fieldArgMap = null + + + def options = QueryTraversalOptions.defaultOptions() + .coerceFieldArguments(false) + QueryTraverser.newQueryTraverser() + .schema(schema) + .document(query) + .coercedVariables(CoercedVariables.of([test: [x: "X"]])) + .options(options) + .build() + .visitPreOrder(queryVisitorStub) + + + then: + fieldArgMap == [:] // empty map + } } From 616343672fb021c77d8e5a2a6f34adae1e6bd38b Mon Sep 17 00:00:00 2001 From: bbaker Date: Sun, 30 Jun 2024 15:28:53 +1000 Subject: [PATCH 51/67] test for options --- .../analysis/QueryTraversalOptionsTest.groovy | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/test/groovy/graphql/analysis/QueryTraversalOptionsTest.groovy diff --git a/src/test/groovy/graphql/analysis/QueryTraversalOptionsTest.groovy b/src/test/groovy/graphql/analysis/QueryTraversalOptionsTest.groovy new file mode 100644 index 0000000000..379c58e820 --- /dev/null +++ b/src/test/groovy/graphql/analysis/QueryTraversalOptionsTest.groovy @@ -0,0 +1,20 @@ +package graphql.analysis + +import spock.lang.Specification + +class QueryTraversalOptionsTest extends Specification { + + def "defaulting works as expected"() { + when: + def options = QueryTraversalOptions.defaultOptions() + + then: + options.isCoerceFieldArguments() + + when: + options = QueryTraversalOptions.defaultOptions().coerceFieldArguments(false) + + then: + !options.isCoerceFieldArguments() + } +} From 467b9bdf7d84c83edc088b89c7d59c4958942825 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 16:44:10 +0000 Subject: [PATCH 52/67] Bump org.junit.jupiter:junit-jupiter from 5.10.2 to 5.10.3 Bumps [org.junit.jupiter:junit-jupiter](https://github.com/junit-team/junit5) from 5.10.2 to 5.10.3. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.10.2...r5.10.3) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- agent-test/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-test/build.gradle b/agent-test/build.gradle index a968428d36..5b1c3e4cfc 100644 --- a/agent-test/build.gradle +++ b/agent-test/build.gradle @@ -6,7 +6,7 @@ dependencies { implementation(rootProject) implementation("net.bytebuddy:byte-buddy-agent:1.14.17") - testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.3' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation("org.assertj:assertj-core:3.26.0") From 80c0aa3f9cca0d5e4f0e31b8d6a9da78ef764219 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 16:44:14 +0000 Subject: [PATCH 53/67] Bump org.codehaus.groovy:groovy from 3.0.21 to 3.0.22 Bumps [org.codehaus.groovy:groovy](https://github.com/apache/groovy) from 3.0.21 to 3.0.22. - [Commits](https://github.com/apache/groovy/commits) --- updated-dependencies: - dependency-name: org.codehaus.groovy:groovy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 905a95614b..99051cd49f 100644 --- a/build.gradle +++ b/build.gradle @@ -107,8 +107,8 @@ dependencies { implementation 'com.google.guava:guava:' + guavaVersion testImplementation group: 'junit', name: 'junit', version: '4.13.2' testImplementation 'org.spockframework:spock-core:2.0-groovy-3.0' - testImplementation 'org.codehaus.groovy:groovy:3.0.21' - testImplementation 'org.codehaus.groovy:groovy-json:3.0.21' + testImplementation 'org.codehaus.groovy:groovy:3.0.22' + testImplementation 'org.codehaus.groovy:groovy-json:3.0.22' testImplementation 'com.google.code.gson:gson:2.11.0' testImplementation 'org.eclipse.jetty:jetty-server:11.0.21' testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' From e4f595415003b227417370aebc93b29ced020161 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Thu, 4 Jul 2024 14:37:31 +1000 Subject: [PATCH 54/67] produce better exception for diffiing bug --- .../java/graphql/schema/diffing/DiffImpl.java | 4 ++ .../schema/diffing/SchemaDiffingTest.groovy | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/main/java/graphql/schema/diffing/DiffImpl.java b/src/main/java/graphql/schema/diffing/DiffImpl.java index efff71cdd0..bbacae14c7 100644 --- a/src/main/java/graphql/schema/diffing/DiffImpl.java +++ b/src/main/java/graphql/schema/diffing/DiffImpl.java @@ -226,6 +226,10 @@ private void addChildToQueue(int fixedEditorialCost, Mapping newMapping = parentPartialMapping.extendMapping(v_i, availableTargetVertices.get(assignments[0])); + if (costMatrixSum >= Integer.MAX_VALUE && optimalEdit.mapping == null) { + throw new RuntimeException("bug: could not find any allowed mapping"); + } + if (lowerBoundForPartialMapping >= optimalEdit.ged) { return; } diff --git a/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy b/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy index 425252519f..efa708dcf4 100644 --- a/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy +++ b/src/test/groovy/graphql/schema/diffing/SchemaDiffingTest.groovy @@ -1554,6 +1554,43 @@ class SchemaDiffingTest extends Specification { operations.size() == 1 } + + /* + * The schema can't be mapped at the moment because + * the arguments mapping doesn't work. + * The PossibleMappingCalculator finds two context: "Object.Query" (with one argument vertex) which is deleted + * and "Object.Foo" (with two argument vertices) which is added. Therefore one isolated vertex is added in the source + * to align both context. + * + * But the parent restrictions dictate that the target parent of i1 must be Query.foo, because Query.echo is fixed mapped + * to Query.foo. But that would mean i1 is deleted, but there is no isolated argument vertex for the target because of + * the contexts. So there is no possible mapping and the exception is thrown. + */ + + def "bug produced well known exception"() { + given: + def schema1 = schema(''' + type Query { + echo(i1: String): String + } + ''') + def schema2 = schema(''' + type Query { + foo: Foo + } + type Foo { + a(i2: String): String + b(i3: String): String + } +''') + + when: + def diff = new SchemaDiffing().diffGraphQLSchema(schema1, schema2) + then: + def e = thrown(RuntimeException) + e.message.contains("bug: ") + } + } From 39d105d516050b5a65b207e52777548cee300fff Mon Sep 17 00:00:00 2001 From: dondonz <13839920+dondonz@users.noreply.github.com> Date: Thu, 4 Jul 2024 17:08:23 +1000 Subject: [PATCH 55/67] Add missing directive definitions --- src/main/java/graphql/Directives.java | 74 ++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/src/main/java/graphql/Directives.java b/src/main/java/graphql/Directives.java index 8e0c81661e..da5bfc80ba 100644 --- a/src/main/java/graphql/Directives.java +++ b/src/main/java/graphql/Directives.java @@ -31,16 +31,25 @@ @PublicApi public class Directives { - private static final String SPECIFIED_BY = "specifiedBy"; private static final String DEPRECATED = "deprecated"; + private static final String INCLUDE = "include"; + private static final String SKIP = "skip"; + private static final String SPECIFIED_BY = "specifiedBy"; private static final String ONE_OF = "oneOf"; private static final String DEFER = "defer"; - public static final String NO_LONGER_SUPPORTED = "No longer supported"; public static final DirectiveDefinition DEPRECATED_DIRECTIVE_DEFINITION; + public static final DirectiveDefinition INCLUDE_DIRECTIVE_DEFINITION; + public static final DirectiveDefinition SKIP_DIRECTIVE_DEFINITION; public static final DirectiveDefinition SPECIFIED_BY_DIRECTIVE_DEFINITION; @ExperimentalApi public static final DirectiveDefinition ONE_OF_DIRECTIVE_DEFINITION; + @ExperimentalApi + public static final DirectiveDefinition DEFER_DIRECTIVE_DEFINITION; + + public static final String BOOLEAN = "Boolean"; + public static final String STRING = "String"; + public static final String NO_LONGER_SUPPORTED = "No longer supported"; static { DEPRECATED_DIRECTIVE_DEFINITION = DirectiveDefinition.newDirectiveDefinition() @@ -54,11 +63,39 @@ public class Directives { newInputValueDefinition() .name("reason") .description(createDescription("The reason for the deprecation")) - .type(newTypeName().name("String").build()) + .type(newTypeName().name(STRING).build()) .defaultValue(StringValue.newStringValue().value(NO_LONGER_SUPPORTED).build()) .build()) .build(); + INCLUDE_DIRECTIVE_DEFINITION = DirectiveDefinition.newDirectiveDefinition() + .name(INCLUDE) + .directiveLocation(newDirectiveLocation().name(FRAGMENT_SPREAD.name()).build()) + .directiveLocation(newDirectiveLocation().name(INLINE_FRAGMENT.name()).build()) + .directiveLocation(newDirectiveLocation().name(FIELD.name()).build()) + .description(createDescription("Directs the executor to include this field or fragment only when the `if` argument is true")) + .inputValueDefinition( + newInputValueDefinition() + .name("if") + .description(createDescription("Included when true.")) + .type(newNonNullType(newTypeName().name(BOOLEAN).build()).build()) + .build()) + .build(); + + SKIP_DIRECTIVE_DEFINITION = DirectiveDefinition.newDirectiveDefinition() + .name(SKIP) + .directiveLocation(newDirectiveLocation().name(FRAGMENT_SPREAD.name()).build()) + .directiveLocation(newDirectiveLocation().name(INLINE_FRAGMENT.name()).build()) + .directiveLocation(newDirectiveLocation().name(FIELD.name()).build()) + .description(createDescription("Directs the executor to skip this field or fragment when the `if` argument is true.")) + .inputValueDefinition( + newInputValueDefinition() + .name("if") + .description(createDescription("Skipped when true.")) + .type(newNonNullType(newTypeName().name(BOOLEAN).build()).build()) + .build()) + .build(); + SPECIFIED_BY_DIRECTIVE_DEFINITION = DirectiveDefinition.newDirectiveDefinition() .name(SPECIFIED_BY) .directiveLocation(newDirectiveLocation().name(SCALAR.name()).build()) @@ -67,7 +104,7 @@ public class Directives { newInputValueDefinition() .name("url") .description(createDescription("The URL that specifies the behaviour of this scalar.")) - .type(newNonNullType(newTypeName().name("String").build()).build()) + .type(newNonNullType(newTypeName().name(STRING).build()).build()) .build()) .build(); @@ -76,6 +113,26 @@ public class Directives { .directiveLocation(newDirectiveLocation().name(INPUT_OBJECT.name()).build()) .description(createDescription("Indicates an Input Object is a OneOf Input Object.")) .build(); + + DEFER_DIRECTIVE_DEFINITION = DirectiveDefinition.newDirectiveDefinition() + .name(DEFER) + .directiveLocation(newDirectiveLocation().name(FRAGMENT_SPREAD.name()).build()) + .directiveLocation(newDirectiveLocation().name(INLINE_FRAGMENT.name()).build()) + .description(createDescription("This directive allows results to be deferred during execution")) + .inputValueDefinition( + newInputValueDefinition() + .name("if") + .description(createDescription("Deferred behaviour is controlled by this argument")) + .type(newNonNullType(newTypeName().name(BOOLEAN).build()).build()) + .defaultValue(BooleanValue.newBooleanValue(true).build()) + .build()) + .inputValueDefinition( + newInputValueDefinition() + .name("label") + .description(createDescription("A unique label that represents the fragment being deferred")) + .type(newTypeName().name(STRING).build()) + .build()) + .build(); } /** @@ -104,33 +161,36 @@ public class Directives { .type(GraphQLString) .description("A unique label that represents the fragment being deferred") ) + .definition(DEFER_DIRECTIVE_DEFINITION) .build(); public static final GraphQLDirective IncludeDirective = GraphQLDirective.newDirective() - .name("include") + .name(INCLUDE) .description("Directs the executor to include this field or fragment only when the `if` argument is true") .argument(newArgument() .name("if") .type(nonNull(GraphQLBoolean)) .description("Included when true.")) .validLocations(FRAGMENT_SPREAD, INLINE_FRAGMENT, FIELD) + .definition(INCLUDE_DIRECTIVE_DEFINITION) .build(); public static final GraphQLDirective SkipDirective = GraphQLDirective.newDirective() - .name("skip") + .name(SKIP) .description("Directs the executor to skip this field or fragment when the `if` argument is true.") .argument(newArgument() .name("if") .type(nonNull(GraphQLBoolean)) .description("Skipped when true.")) .validLocations(FRAGMENT_SPREAD, INLINE_FRAGMENT, FIELD) + .definition(SKIP_DIRECTIVE_DEFINITION) .build(); /** * The "deprecated" directive is special and is always available in a graphql schema *

- * See https://graphql.github.io/graphql-spec/June2018/#sec--deprecated + * See the GraphQL specification for @deprecated */ public static final GraphQLDirective DeprecatedDirective = GraphQLDirective.newDirective() .name(DEPRECATED) From f0bccc7e3c93ad3a6eee951f841abd9e857abfed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 16:09:15 +0000 Subject: [PATCH 56/67] Bump org.eclipse.jetty:jetty-server from 11.0.21 to 11.0.22 Bumps org.eclipse.jetty:jetty-server from 11.0.21 to 11.0.22. --- updated-dependencies: - dependency-name: org.eclipse.jetty:jetty-server dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 99051cd49f..994c55f99a 100644 --- a/build.gradle +++ b/build.gradle @@ -110,7 +110,7 @@ dependencies { testImplementation 'org.codehaus.groovy:groovy:3.0.22' testImplementation 'org.codehaus.groovy:groovy-json:3.0.22' testImplementation 'com.google.code.gson:gson:2.11.0' - testImplementation 'org.eclipse.jetty:jetty-server:11.0.21' + testImplementation 'org.eclipse.jetty:jetty-server:11.0.22' testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' testImplementation 'org.awaitility:awaitility-groovy:4.2.0' testImplementation 'com.github.javafaker:javafaker:1.0.2' From c7d6a3a57e3ea53bd84c14f105c51b81969adb1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 21:36:27 +0000 Subject: [PATCH 57/67] Bump com.fasterxml.jackson.core:jackson-databind from 2.17.1 to 2.17.2 Bumps [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson) from 2.17.1 to 2.17.2. - [Commits](https://github.com/FasterXML/jackson/commits) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.core:jackson-databind dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 994c55f99a..275dcb3ad8 100644 --- a/build.gradle +++ b/build.gradle @@ -111,7 +111,7 @@ dependencies { testImplementation 'org.codehaus.groovy:groovy-json:3.0.22' testImplementation 'com.google.code.gson:gson:2.11.0' testImplementation 'org.eclipse.jetty:jetty-server:11.0.22' - testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' + testImplementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' testImplementation 'org.awaitility:awaitility-groovy:4.2.0' testImplementation 'com.github.javafaker:javafaker:1.0.2' From 36df473af78f5bacfb73a45816758d5709ba846a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jul 2024 16:48:53 +0000 Subject: [PATCH 58/67] Bump net.bytebuddy:byte-buddy from 1.14.17 to 1.14.18 Bumps [net.bytebuddy:byte-buddy](https://github.com/raphw/byte-buddy) from 1.14.17 to 1.14.18. - [Release notes](https://github.com/raphw/byte-buddy/releases) - [Changelog](https://github.com/raphw/byte-buddy/blob/master/release-notes.md) - [Commits](https://github.com/raphw/byte-buddy/compare/byte-buddy-1.14.17...byte-buddy-1.14.18) --- updated-dependencies: - dependency-name: net.bytebuddy:byte-buddy dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- agent/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/build.gradle b/agent/build.gradle index 1c36c86288..1dfeba3f27 100644 --- a/agent/build.gradle +++ b/agent/build.gradle @@ -6,7 +6,7 @@ plugins { } dependencies { - implementation("net.bytebuddy:byte-buddy:1.14.17") + implementation("net.bytebuddy:byte-buddy:1.14.18") // graphql-java itself implementation(rootProject) } From ad5bb2b18a9f7a94d593e602c0141fa47e3a73b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jul 2024 16:48:56 +0000 Subject: [PATCH 59/67] Bump org.assertj:assertj-core from 3.26.0 to 3.26.3 Bumps [org.assertj:assertj-core](https://github.com/assertj/assertj) from 3.26.0 to 3.26.3. - [Release notes](https://github.com/assertj/assertj/releases) - [Commits](https://github.com/assertj/assertj/compare/assertj-build-3.26.0...assertj-build-3.26.3) --- updated-dependencies: - dependency-name: org.assertj:assertj-core dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- agent-test/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-test/build.gradle b/agent-test/build.gradle index 5b1c3e4cfc..f07e1a29f6 100644 --- a/agent-test/build.gradle +++ b/agent-test/build.gradle @@ -9,7 +9,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.3' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testImplementation("org.assertj:assertj-core:3.26.0") + testImplementation("org.assertj:assertj-core:3.26.3") } From 8c223b2a96bd5b0a2265d37281f3944170c1d801 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jul 2024 16:49:01 +0000 Subject: [PATCH 60/67] Bump io.projectreactor:reactor-core from 3.6.7 to 3.6.8 Bumps [io.projectreactor:reactor-core](https://github.com/reactor/reactor-core) from 3.6.7 to 3.6.8. - [Release notes](https://github.com/reactor/reactor-core/releases) - [Commits](https://github.com/reactor/reactor-core/compare/v3.6.7...v3.6.8) --- updated-dependencies: - dependency-name: io.projectreactor:reactor-core dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 275dcb3ad8..07a6456b03 100644 --- a/build.gradle +++ b/build.gradle @@ -117,7 +117,7 @@ dependencies { testImplementation 'org.reactivestreams:reactive-streams-tck:' + reactiveStreamsVersion testImplementation "io.reactivex.rxjava2:rxjava:2.2.21" - testImplementation "io.projectreactor:reactor-core:3.6.7" + testImplementation "io.projectreactor:reactor-core:3.6.8" testImplementation 'org.testng:testng:7.10.2' // use for reactive streams test inheritance From 2a8b23bf22abc2276afe6c0b2e7aa54b48e1771e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jul 2024 16:49:06 +0000 Subject: [PATCH 61/67] Bump net.bytebuddy:byte-buddy-agent from 1.14.17 to 1.14.18 Bumps [net.bytebuddy:byte-buddy-agent](https://github.com/raphw/byte-buddy) from 1.14.17 to 1.14.18. - [Release notes](https://github.com/raphw/byte-buddy/releases) - [Changelog](https://github.com/raphw/byte-buddy/blob/master/release-notes.md) - [Commits](https://github.com/raphw/byte-buddy/compare/byte-buddy-1.14.17...byte-buddy-1.14.18) --- updated-dependencies: - dependency-name: net.bytebuddy:byte-buddy-agent dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- agent-test/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-test/build.gradle b/agent-test/build.gradle index 5b1c3e4cfc..57703e5a06 100644 --- a/agent-test/build.gradle +++ b/agent-test/build.gradle @@ -4,7 +4,7 @@ plugins { dependencies { implementation(rootProject) - implementation("net.bytebuddy:byte-buddy-agent:1.14.17") + implementation("net.bytebuddy:byte-buddy-agent:1.14.18") testImplementation 'org.junit.jupiter:junit-jupiter:5.10.3' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' From 4cd20228807c21a7a1fd697b5ab3de06521e4fbf Mon Sep 17 00:00:00 2001 From: Aileen Chen Date: Mon, 22 Jul 2024 16:22:14 -0700 Subject: [PATCH 62/67] Fix MultiSourceReader --- .../graphql/parser/MultiSourceReader.java | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/main/java/graphql/parser/MultiSourceReader.java b/src/main/java/graphql/parser/MultiSourceReader.java index acbcef40e0..b8553ec1d7 100644 --- a/src/main/java/graphql/parser/MultiSourceReader.java +++ b/src/main/java/graphql/parser/MultiSourceReader.java @@ -23,6 +23,10 @@ @PublicApi public class MultiSourceReader extends Reader { + // In Java version 16+, LineNumberReader.read considers end-of-stream to be a line terminator + // and will increment the line number, whereas in previous versions it doesn't. + private static final boolean LINE_NUMBER_READER_EOS_IS_TERMINATOR; + private final List sourceParts; private final StringBuilder data = new StringBuilder(); private int currentIndex = 0; @@ -30,6 +34,17 @@ public class MultiSourceReader extends Reader { private final boolean trackData; private final LockKit.ReentrantLock readerLock = new LockKit.ReentrantLock(); + static { + LineNumberReader reader = new LineNumberReader(new StringReader("a")); + try { + reader.read(); + reader.read(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + LINE_NUMBER_READER_EOS_IS_TERMINATOR = reader.getLineNumber() > 0; + } + private MultiSourceReader(Builder builder) { this.sourceParts = builder.sourceParts; @@ -46,10 +61,16 @@ public int read(char[] cbuf, int off, int len) throws IOException { } SourcePart sourcePart = sourceParts.get(currentIndex); int read = sourcePart.lineReader.read(cbuf, off, len); - overallLineNumber = calcLineNumber(); if (read == -1) { currentIndex++; - } else { + sourcePart.reachedEndOfStream = true; + } else if (read > 0) { + sourcePart.lastRead = cbuf[off + read - 1]; + } + // note: calcLineNumber() must be called after updating sourcePart.reachedEndOfStream + // and sourcePart.lastRead + overallLineNumber = calcLineNumber(); + if (read != -1) { trackData(cbuf, off, read); return read; } @@ -68,7 +89,7 @@ private void trackData(char[] cbuf, int off, int len) { private int calcLineNumber() { int linenumber = 0; for (SourcePart sourcePart : sourceParts) { - linenumber += sourcePart.lineReader.getLineNumber(); + linenumber += sourcePart.getLineNumber(); } return linenumber; } @@ -125,7 +146,7 @@ public SourceAndLine getSourceAndLineFromOverallLine(int overallLineNumber) { sourceAndLine.sourceName = sourcePart.sourceName; if (sourcePart == currentPart) { // we cant go any further - int partLineNumber = currentPart.lineReader.getLineNumber(); + int partLineNumber = currentPart.getLineNumber(); previousPage = page; page += partLineNumber; if (page > overallLineNumber) { @@ -136,7 +157,7 @@ public SourceAndLine getSourceAndLineFromOverallLine(int overallLineNumber) { return sourceAndLine; } else { previousPage = page; - int partLineNumber = sourcePart.lineReader.getLineNumber(); + int partLineNumber = sourcePart.getLineNumber(); page += partLineNumber; if (page > overallLineNumber) { sourceAndLine.line = overallLineNumber - previousPage; @@ -157,9 +178,9 @@ public int getLineNumber() { return 0; } if (currentIndex >= sourceParts.size()) { - return sourceParts.get(sourceParts.size() - 1).lineReader.getLineNumber(); + return sourceParts.get(sourceParts.size() - 1).getLineNumber(); } - return sourceParts.get(currentIndex).lineReader.getLineNumber(); + return sourceParts.get(currentIndex).getLineNumber(); }); } @@ -220,6 +241,24 @@ private static class SourcePart { String sourceName; LineNumberReader lineReader; boolean closed; + char lastRead; + boolean reachedEndOfStream = false; + + /** + * This handles the discrepancy between LineNumberReader.getLineNumber() for Java versions + * 16+ vs below. Use this instead of lineReader.getLineNumber() directly. + * @return The current line number. EOS is not considered a line terminator. + */ + int getLineNumber() { + int lineNumber = lineReader.getLineNumber(); + if (reachedEndOfStream + && LINE_NUMBER_READER_EOS_IS_TERMINATOR + && lastRead != '\r' + && lastRead != '\n') { + return Math.max(lineNumber - 1, 0); + } + return lineNumber; + } } From 914f49b605212439aec839bc1f4265584304e277 Mon Sep 17 00:00:00 2001 From: Aileen Chen Date: Tue, 23 Jul 2024 16:17:34 -0700 Subject: [PATCH 63/67] use static function to init --- src/main/java/graphql/parser/MultiSourceReader.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/graphql/parser/MultiSourceReader.java b/src/main/java/graphql/parser/MultiSourceReader.java index b8553ec1d7..fad54b2fa0 100644 --- a/src/main/java/graphql/parser/MultiSourceReader.java +++ b/src/main/java/graphql/parser/MultiSourceReader.java @@ -35,6 +35,10 @@ public class MultiSourceReader extends Reader { private final LockKit.ReentrantLock readerLock = new LockKit.ReentrantLock(); static { + LINE_NUMBER_READER_EOS_IS_TERMINATOR = lineNumberReaderEOSIsTerminator(); + } + + private static boolean lineNumberReaderEOSIsTerminator() { LineNumberReader reader = new LineNumberReader(new StringReader("a")); try { reader.read(); @@ -42,7 +46,7 @@ public class MultiSourceReader extends Reader { } catch (IOException e) { throw new UncheckedIOException(e); } - LINE_NUMBER_READER_EOS_IS_TERMINATOR = reader.getLineNumber() > 0; + return reader.getLineNumber() > 0; } From 389a9b47da962db5f8b7805fa63a4f980b605520 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Thu, 25 Jul 2024 13:14:04 +1000 Subject: [PATCH 64/67] upgrade gradle to 8.9 --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a80b22ce5c..09523c0e54 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From bd67c786914a40fa4426a8776d74d18661a2cc2c Mon Sep 17 00:00:00 2001 From: bbaker Date: Mon, 29 Jul 2024 09:14:00 +1000 Subject: [PATCH 65/67] 3662 - fixes dataloader dispatching during subscriptions --- .../graphql/execution/ExecutionContext.java | 1 + .../execution/ExecutionContextBuilder.java | 8 ++ .../ExecutionContextBuilderTest.groovy | 85 +++++++++++++------ .../DataLoaderDispatcherTest.groovy | 83 ++++++++++++++++++ 4 files changed, 150 insertions(+), 27 deletions(-) diff --git a/src/main/java/graphql/execution/ExecutionContext.java b/src/main/java/graphql/execution/ExecutionContext.java index e459cbe0ad..c122f9d2c3 100644 --- a/src/main/java/graphql/execution/ExecutionContext.java +++ b/src/main/java/graphql/execution/ExecutionContext.java @@ -86,6 +86,7 @@ public class ExecutionContext { this.errors.set(builder.errors); this.localContext = builder.localContext; this.executionInput = builder.executionInput; + this.dataLoaderDispatcherStrategy = builder.dataLoaderDispatcherStrategy; this.queryTree = FpKit.interThreadMemoize(() -> ExecutableNormalizedOperationFactory.createExecutableNormalizedOperation(graphQLSchema, operationDefinition, fragmentsByName, coercedVariables)); } diff --git a/src/main/java/graphql/execution/ExecutionContextBuilder.java b/src/main/java/graphql/execution/ExecutionContextBuilder.java index 7220e8dee7..b60c793b20 100644 --- a/src/main/java/graphql/execution/ExecutionContextBuilder.java +++ b/src/main/java/graphql/execution/ExecutionContextBuilder.java @@ -45,6 +45,7 @@ public class ExecutionContextBuilder { ValueUnboxer valueUnboxer; Object localContext; ExecutionInput executionInput; + DataLoaderDispatchStrategy dataLoaderDispatcherStrategy = DataLoaderDispatchStrategy.NO_OP; /** * @return a new builder of {@link graphql.execution.ExecutionContext}s @@ -90,6 +91,7 @@ public ExecutionContextBuilder() { errors = ImmutableList.copyOf(other.getErrors()); valueUnboxer = other.getValueUnboxer(); executionInput = other.getExecutionInput(); + dataLoaderDispatcherStrategy = other.getDataLoaderDispatcherStrategy(); } public ExecutionContextBuilder instrumentation(Instrumentation instrumentation) { @@ -203,6 +205,12 @@ public ExecutionContextBuilder executionInput(ExecutionInput executionInput) { return this; } + @Internal + public ExecutionContextBuilder dataLoaderDispatcherStrategy(DataLoaderDispatchStrategy dataLoaderDispatcherStrategy) { + this.dataLoaderDispatcherStrategy = dataLoaderDispatcherStrategy; + return this; + } + public ExecutionContextBuilder resetErrors() { this.errors = emptyList(); return this; diff --git a/src/test/groovy/graphql/execution/ExecutionContextBuilderTest.groovy b/src/test/groovy/graphql/execution/ExecutionContextBuilderTest.groovy index 210c442989..6fdbeef3f4 100644 --- a/src/test/groovy/graphql/execution/ExecutionContextBuilderTest.groovy +++ b/src/test/groovy/graphql/execution/ExecutionContextBuilderTest.groovy @@ -25,24 +25,26 @@ class ExecutionContextBuilderTest extends Specification { def operation = document.definitions[0] as OperationDefinition def fragment = document.definitions[1] as FragmentDefinition def dataLoaderRegistry = new DataLoaderRegistry() + def mockDataLoaderDispatcherStrategy = Mock(DataLoaderDispatchStrategy) def "builds the correct ExecutionContext"() { when: def executionContext = new ExecutionContextBuilder() - .instrumentation(instrumentation) - .queryStrategy(queryStrategy) - .mutationStrategy(mutationStrategy) - .subscriptionStrategy(subscriptionStrategy) - .graphQLSchema(schema) - .executionId(executionId) - .context(context) // Retain deprecated builder for test coverage - .graphQLContext(graphQLContext) - .root(root) - .operationDefinition(operation) - .fragmentsByName([MyFragment: fragment]) - .variables([var: 'value']) // Retain deprecated builder for test coverage - .dataLoaderRegistry(dataLoaderRegistry) - .build() + .instrumentation(instrumentation) + .queryStrategy(queryStrategy) + .mutationStrategy(mutationStrategy) + .subscriptionStrategy(subscriptionStrategy) + .graphQLSchema(schema) + .executionId(executionId) + .context(context) // Retain deprecated builder for test coverage + .graphQLContext(graphQLContext) + .root(root) + .operationDefinition(operation) + .fragmentsByName([MyFragment: fragment]) + .variables([var: 'value']) // Retain deprecated builder for test coverage + .dataLoaderRegistry(dataLoaderRegistry) + .dataLoaderDispatcherStrategy(mockDataLoaderDispatcherStrategy) + .build() then: executionContext.executionId == executionId @@ -58,6 +60,7 @@ class ExecutionContextBuilderTest extends Specification { executionContext.getFragmentsByName() == [MyFragment: fragment] executionContext.operationDefinition == operation executionContext.dataLoaderRegistry == dataLoaderRegistry + executionContext.dataLoaderDispatcherStrategy == mockDataLoaderDispatcherStrategy } def "builds the correct ExecutionContext with coerced variables"() { @@ -66,19 +69,19 @@ class ExecutionContextBuilderTest extends Specification { when: def executionContext = new ExecutionContextBuilder() - .instrumentation(instrumentation) - .queryStrategy(queryStrategy) - .mutationStrategy(mutationStrategy) - .subscriptionStrategy(subscriptionStrategy) - .graphQLSchema(schema) - .executionId(executionId) - .graphQLContext(graphQLContext) - .root(root) - .operationDefinition(operation) - .fragmentsByName([MyFragment: fragment]) - .coercedVariables(coercedVariables) - .dataLoaderRegistry(dataLoaderRegistry) - .build() + .instrumentation(instrumentation) + .queryStrategy(queryStrategy) + .mutationStrategy(mutationStrategy) + .subscriptionStrategy(subscriptionStrategy) + .graphQLSchema(schema) + .executionId(executionId) + .graphQLContext(graphQLContext) + .root(root) + .operationDefinition(operation) + .fragmentsByName([MyFragment: fragment]) + .coercedVariables(coercedVariables) + .dataLoaderRegistry(dataLoaderRegistry) + .build() then: executionContext.executionId == executionId @@ -205,4 +208,32 @@ class ExecutionContextBuilderTest extends Specification { executionContext.operationDefinition == operation executionContext.dataLoaderRegistry == dataLoaderRegistry } + + def "transform copies dispatcher"() { + given: + def oldCoercedVariables = CoercedVariables.emptyVariables() + def executionContextOld = new ExecutionContextBuilder() + .instrumentation(instrumentation) + .queryStrategy(queryStrategy) + .mutationStrategy(mutationStrategy) + .subscriptionStrategy(subscriptionStrategy) + .graphQLSchema(schema) + .executionId(executionId) + .graphQLContext(graphQLContext) + .root(root) + .operationDefinition(operation) + .coercedVariables(oldCoercedVariables) + .fragmentsByName([MyFragment: fragment]) + .dataLoaderRegistry(dataLoaderRegistry) + .dataLoaderDispatcherStrategy(DataLoaderDispatchStrategy.NO_OP) + .build() + + when: + def executionContext = executionContextOld + .transform(builder -> builder + .dataLoaderDispatcherStrategy(mockDataLoaderDispatcherStrategy)) + + then: + executionContext.getDataLoaderDispatcherStrategy() == mockDataLoaderDispatcherStrategy + } } diff --git a/src/test/groovy/graphql/execution/instrumentation/dataloader/DataLoaderDispatcherTest.groovy b/src/test/groovy/graphql/execution/instrumentation/dataloader/DataLoaderDispatcherTest.groovy index 7eaa9cec10..839621f727 100644 --- a/src/test/groovy/graphql/execution/instrumentation/dataloader/DataLoaderDispatcherTest.groovy +++ b/src/test/groovy/graphql/execution/instrumentation/dataloader/DataLoaderDispatcherTest.groovy @@ -1,6 +1,7 @@ package graphql.execution.instrumentation.dataloader import graphql.ExecutionInput +import graphql.ExecutionResult import graphql.GraphQL import graphql.TestUtil import graphql.execution.AsyncSerialExecutionStrategy @@ -8,11 +9,16 @@ import graphql.execution.instrumentation.ChainedInstrumentation import graphql.execution.instrumentation.InstrumentationState import graphql.execution.instrumentation.SimplePerformantInstrumentation import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters +import graphql.execution.pubsub.CapturingSubscriber import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment +import org.awaitility.Awaitility import org.dataloader.BatchLoader import org.dataloader.DataLoaderFactory import org.dataloader.DataLoaderRegistry import org.jetbrains.annotations.NotNull +import org.reactivestreams.Publisher +import reactor.core.publisher.Mono import spock.lang.Specification import spock.lang.Unroll @@ -275,4 +281,81 @@ class DataLoaderDispatcherTest extends Specification { er.errors.isEmpty() er.data == support.buildResponse(depth) } + + def "issue 3662 - dataloader dispatching can work with subscriptions"() { + + def sdl = ''' + type Query { + field : String + } + + type Subscription { + onSub : OnSub + } + + type OnSub { + x : String + y : String + } + ''' + + // the dispatching is ALWAYS so not really batching but it completes + BatchLoader batchLoader = { keys -> + CompletableFuture.supplyAsync { + Thread.sleep(50) // some delay + keys + } + } + + DataFetcher dlDF = { DataFetchingEnvironment env -> + def dataLoader = env.getDataLoaderRegistry().getDataLoader("dl") + return dataLoader.load("working as expected") + } + DataFetcher dlSub = { DataFetchingEnvironment env -> + return Mono.just([x: "X", y: "Y"]) + } + def runtimeWiring = newRuntimeWiring() + .type(newTypeWiring("OnSub") + .dataFetcher("x", dlDF) + .dataFetcher("y", dlDF) + .build() + ) + .type(newTypeWiring("Subscription") + .dataFetcher("onSub", dlSub) + .build() + ) + .build() + + def graphql = TestUtil.graphQL(sdl, runtimeWiring).build() + + DataLoaderRegistry dataLoaderRegistry = new DataLoaderRegistry() + dataLoaderRegistry.register("dl", DataLoaderFactory.newDataLoader(batchLoader)) + + when: + def query = """ + subscription s { + onSub { + x, y + } + } + """ + def executionInput = newExecutionInput() + .dataLoaderRegistry(dataLoaderRegistry) + .query(query) + .build() + def er = graphql.execute(executionInput) + + then: + er.errors.isEmpty() + def subscriber = new CapturingSubscriber() + Publisher pub = er.data + pub.subscribe(subscriber) + + Awaitility.await().untilTrue(subscriber.isDone()) + + subscriber.getEvents().size() == 1 + + def msgER = subscriber.getEvents()[0] as ExecutionResult + msgER.data == [onSub: [x: "working as expected", y: "working as expected"]] + } } From 7eb938276adc0b396cd61be7c3ba02e74309d85f Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Mon, 29 Jul 2024 18:06:15 +1000 Subject: [PATCH 66/67] upgrade gradle wrapper --- gradle/wrapper/gradle-wrapper.jar | Bin 43462 -> 43504 bytes gradlew | 7 +++++-- gradlew.bat | 22 ++++++++++++---------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d64cd4917707c1f8861d8cb53dd15194d4248596..2c3521197d7c4586c843d1d3e9090525f1898cde 100644 GIT binary patch delta 34463 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 SXRcJ{65?fGI57uQ5&!`B?F@7P delta 34554 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 diff --git a/gradlew b/gradlew index 1aa94a4269..f5feea6d6b 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 6689b85bee..9b42019c79 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail From ec8bafb0ef8931466b9ec52d59aa1c05bffb2c96 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Thu, 1 Aug 2024 14:50:37 +1000 Subject: [PATCH 67/67] fix sorting improve performance for calc lower bounds costs --- .../java/graphql/schema/diffing/DiffImpl.java | 113 +++++++----------- .../graphql/schema/diffing/SchemaDiffing.java | 4 +- 2 files changed, 48 insertions(+), 69 deletions(-) diff --git a/src/main/java/graphql/schema/diffing/DiffImpl.java b/src/main/java/graphql/schema/diffing/DiffImpl.java index bbacae14c7..f24eb934a6 100644 --- a/src/main/java/graphql/schema/diffing/DiffImpl.java +++ b/src/main/java/graphql/schema/diffing/DiffImpl.java @@ -444,41 +444,9 @@ private double calcLowerBoundMappingCost(Vertex v, boolean equalNodes = v.getType().equals(u.getType()) && v.getProperties().equals(u.getProperties()); - Collection adjacentEdgesV = completeSourceGraph.getAdjacentEdgesNonCopy(v); - Multiset multisetLabelsV = HashMultiset.create(); - - for (Edge edge : adjacentEdgesV) { - // test if this is an inner edge (meaning it not part of the subgraph induced by the partial mapping) - // we know that v is not part of the mapped vertices, therefore we only need to test the "to" vertex - if (!partialMapping.containsSource(edge.getTo())) { - multisetLabelsV.add(edge.getLabel()); - } - } - - Collection adjacentEdgesU = completeTargetGraph.getAdjacentEdgesNonCopy(u); - Multiset multisetLabelsU = HashMultiset.create(); - for (Edge edge : adjacentEdgesU) { - // test if this is an inner edge (meaning it not part of the subgraph induced by the partial mapping) - // we know that u is not part of the mapped vertices, therefore we only need to test the "to" vertex - if (!partialMapping.containsTarget(edge.getTo())) { - multisetLabelsU.add(edge.getLabel()); - } - } - - int anchoredVerticesCost = calcAnchoredVerticesCost(v, u, partialMapping); - - Multiset intersection = Multisets.intersection(multisetLabelsV, multisetLabelsU); - int multiSetEditDistance = Math.max(multisetLabelsV.size(), multisetLabelsU.size()) - intersection.size(); - - double result = (equalNodes ? 0 : 1) + multiSetEditDistance + anchoredVerticesCost; - return result; - } - - - private int calcAnchoredVerticesCost(Vertex v, - Vertex u, - Mapping partialMapping) { int anchoredVerticesCost = 0; + Multiset multisetInnerEdgeLabelsV = HashMultiset.create(); + Multiset multisetInnerEdgeLabelsU = HashMultiset.create(); Collection adjacentEdgesV = completeSourceGraph.getAdjacentEdgesNonCopy(v); Collection adjacentEdgesU = completeTargetGraph.getAdjacentEdgesNonCopy(u); @@ -489,55 +457,60 @@ private int calcAnchoredVerticesCost(Vertex v, Set matchedTargetEdges = new LinkedHashSet<>(); Set matchedTargetEdgesInverse = new LinkedHashSet<>(); - outer: for (Edge edgeV : adjacentEdgesV) { - // we are only interested in edges from anchored vertices - if (!partialMapping.containsSource(edgeV.getTo())) { + + Vertex targetTo = partialMapping.getTarget(edgeV.getTo()); + if (targetTo == null) { + // meaning it is an inner edge(not part of the subgraph induced by the partial mapping) + multisetInnerEdgeLabelsV.add(edgeV.getLabel()); continue; } - for (Edge edgeU : adjacentEdgesU) { - // looking for an adjacent edge from u matching it - if (partialMapping.getTarget(edgeV.getTo()) == edgeU.getTo()) { - matchedTargetEdges.add(edgeU); - // found two adjacent edges, comparing the labels - if (!Objects.equals(edgeV.getLabel(), edgeU.getLabel())) { - anchoredVerticesCost++; - } - continue outer; + /* question is if the edge from v is mapped onto an edge from u + (also edge are not mapped directly, but the vertices are) + and if the adjacent edge is mapped onto an adjacent edge, + we need to check the labels of the edges + */ + Edge matchedTargetEdge = completeTargetGraph.getEdge(u, targetTo); + if (matchedTargetEdge != null) { + matchedTargetEdges.add(matchedTargetEdge); + if (!Objects.equals(edgeV.getLabel(), matchedTargetEdge.getLabel())) { + anchoredVerticesCost++; } + } else { +// // no matching adjacent edge from u found means there is no +// // edge from edgeV.getTo() to mapped(edgeV.getTo()) +// // and we need to increase the costs + anchoredVerticesCost++; } - // no matching adjacent edge from u found means there is no - // edge from edgeV.getTo() to mapped(edgeV.getTo()) - // and we need to increase the costs - anchoredVerticesCost++; } - outer: for (Edge edgeV : adjacentEdgesInverseV) { + + Vertex targetFrom = partialMapping.getTarget(edgeV.getFrom()); // we are only interested in edges from anchored vertices - if (!partialMapping.containsSource(edgeV.getFrom())) { + if (targetFrom == null) { continue; } - for (Edge edgeU : adjacentEdgesInverseU) { - if (partialMapping.getTarget(edgeV.getFrom()) == edgeU.getFrom()) { - matchedTargetEdgesInverse.add(edgeU); - if (!Objects.equals(edgeV.getLabel(), edgeU.getLabel())) { - anchoredVerticesCost++; - } - continue outer; + Edge matachedTargetEdge = completeTargetGraph.getEdge(targetFrom, u); + if (matachedTargetEdge != null) { + matchedTargetEdgesInverse.add(matachedTargetEdge); + if (!Objects.equals(edgeV.getLabel(), matachedTargetEdge.getLabel())) { + anchoredVerticesCost++; } + } else { + anchoredVerticesCost++; } - anchoredVerticesCost++; - } - /** - * what is missing now is all edges from u (and inverse), which have not been matched. - */ for (Edge edgeU : adjacentEdgesU) { - // we are only interested in edges from anchored vertices - if (!partialMapping.containsTarget(edgeU.getTo()) || matchedTargetEdges.contains(edgeU)) { + // test if this is an inner edge (meaning it not part of the subgraph induced by the partial mapping) + // we know that u is not part of the mapped vertices, therefore we only need to test the "to" vertex + if (!partialMapping.containsTarget(edgeU.getTo())) { + multisetInnerEdgeLabelsU.add(edgeU.getLabel()); + continue; + } + if (matchedTargetEdges.contains(edgeU)) { continue; } anchoredVerticesCost++; @@ -551,7 +524,12 @@ private int calcAnchoredVerticesCost(Vertex v, anchoredVerticesCost++; } - return anchoredVerticesCost; + + Multiset intersectionInnerEdgeLabels = Multisets.intersection(multisetInnerEdgeLabelsV, multisetInnerEdgeLabelsU); + int multiSetEditDistanceForInnerEdges = Math.max(multisetInnerEdgeLabelsV.size(), multisetInnerEdgeLabelsU.size()) - intersectionInnerEdgeLabels.size(); + + int result = (equalNodes ? 0 : 1) + multiSetEditDistanceForInnerEdges + anchoredVerticesCost; + return result; } @@ -578,5 +556,4 @@ private double calcLowerBoundMappingCostForIsolated(Vertex vertex, return 1 + adjacentEdges.size() + anchoredInverseEdges; } - } diff --git a/src/main/java/graphql/schema/diffing/SchemaDiffing.java b/src/main/java/graphql/schema/diffing/SchemaDiffing.java index de047bf1fd..b12f8aec5a 100644 --- a/src/main/java/graphql/schema/diffing/SchemaDiffing.java +++ b/src/main/java/graphql/schema/diffing/SchemaDiffing.java @@ -96,15 +96,17 @@ private DiffImpl.OptimalEdit diffImpl(SchemaGraph sourceGraph, SchemaGraph targe Multimaps.invertFrom(possibleMappings.possibleMappings, invertedPossibleOnes); possibleMappings.possibleMappings = invertedPossibleOnes; + sortVertices(nonMappedTarget, targetGraph, possibleMappings); + List sourceVertices = new ArrayList<>(); sourceVertices.addAll(possibleMappings.fixedOneToOneSources); sourceVertices.addAll(nonMappedSource); + List targetVertices = new ArrayList<>(); targetVertices.addAll(possibleMappings.fixedOneToOneTargets); targetVertices.addAll(nonMappedTarget); - sortVertices(nonMappedTarget, targetGraph, possibleMappings); DiffImpl diffImpl = new DiffImpl(possibleMappingsCalculator, targetGraph, sourceGraph, possibleMappings, runningCheck); DiffImpl.OptimalEdit optimalEdit = diffImpl.diffImpl(startMappingInverted, targetVertices, sourceVertices, algoIterationCount);