diff --git a/build.gradle b/build.gradle
index 8cc48c3a7f..7221f154da 100644
--- a/build.gradle
+++ b/build.gradle
@@ -126,6 +126,12 @@ dependencies {
implementation 'org.antlr:antlr4-runtime:' + antlrVersion
implementation 'com.google.guava:guava:' + guavaVersion
+ // we can compile against caffeine but its not shipped as a runtime dependency
+ compileOnly 'com.github.ben-manes.caffeine:caffeine:3.1.8'
+ // we need caffeine to write tests however
+ testImplementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
+
+
testImplementation group: 'junit', name: 'junit', version: '4.13.2'
testImplementation 'org.spockframework:spock-core:2.3-groovy-4.0'
testImplementation 'net.bytebuddy:byte-buddy:1.17.7'
diff --git a/src/jmh/java/benchmark/CachingDocumentBenchmark.java b/src/jmh/java/benchmark/CachingDocumentBenchmark.java
new file mode 100644
index 0000000000..bf15bb4f7f
--- /dev/null
+++ b/src/jmh/java/benchmark/CachingDocumentBenchmark.java
@@ -0,0 +1,83 @@
+package benchmark;
+
+import graphql.GraphQL;
+import graphql.StarWarsSchema;
+import graphql.execution.preparsed.NoOpPreparsedDocumentProvider;
+import graphql.execution.preparsed.PreparsedDocumentProvider;
+import graphql.execution.preparsed.caching.CachingDocumentProvider;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+
+import java.util.concurrent.TimeUnit;
+
+@State(Scope.Benchmark)
+@Warmup(iterations = 2, time = 5)
+@Measurement(iterations = 3, time = 2)
+@Fork(2)
+public class CachingDocumentBenchmark {
+
+ @Param({"50", "500", "5000"})
+ public int querySize;
+
+ @Param({"10", "50", "500"})
+ public int queryCount;
+
+ @Setup(Level.Trial)
+ public void setUp() {
+ }
+
+ private static final GraphQL GRAPHQL_CACHING_ON = buildGraphQL(true);
+ private static final GraphQL GRAPHQL_CACHING_OFF = buildGraphQL(false);
+
+ @Benchmark
+ @BenchmarkMode(Mode.AverageTime)
+ @OutputTimeUnit(TimeUnit.MILLISECONDS)
+ public void benchMarkCachingOnAvgTime() {
+ executeQuery(true);
+ }
+
+ @Benchmark
+ @BenchmarkMode(Mode.AverageTime)
+ @OutputTimeUnit(TimeUnit.MILLISECONDS)
+ public void benchMarkCachingOffAvgTime() {
+ executeQuery(false);
+ }
+
+ public void executeQuery(boolean cachingOn) {
+ String query = buildQuery(querySize);
+ GraphQL graphQL = cachingOn ? GRAPHQL_CACHING_ON : GRAPHQL_CACHING_OFF;
+
+ for (int i = 0; i < queryCount; i++) {
+ graphQL.execute(query);
+ }
+ }
+
+ private static String buildQuery(int howManyAliases) {
+ StringBuilder query = new StringBuilder("query q { hero { \n");
+ for (int i = 0; i < howManyAliases; i++) {
+ query.append("nameAlias").append(i).append(" : name\n");
+ }
+ query.append("}}");
+ return query.toString();
+ }
+
+ private static GraphQL buildGraphQL(boolean cachingOn) {
+ PreparsedDocumentProvider documentProvider = NoOpPreparsedDocumentProvider.INSTANCE;
+ if (cachingOn) {
+ documentProvider = new CachingDocumentProvider();
+ }
+ return GraphQL.newGraphQL(StarWarsSchema.starWarsSchema)
+ .preparsedDocumentProvider(documentProvider)
+ .build();
+ }
+}
diff --git a/src/main/java/graphql/GraphQL.java b/src/main/java/graphql/GraphQL.java
index 16d14ab4b9..ef9e66f8fb 100644
--- a/src/main/java/graphql/GraphQL.java
+++ b/src/main/java/graphql/GraphQL.java
@@ -23,6 +23,7 @@
import graphql.execution.preparsed.NoOpPreparsedDocumentProvider;
import graphql.execution.preparsed.PreparsedDocumentEntry;
import graphql.execution.preparsed.PreparsedDocumentProvider;
+import graphql.execution.preparsed.caching.CachingDocumentProvider;
import graphql.language.Document;
import graphql.schema.GraphQLSchema;
import graphql.validation.ValidationError;
@@ -279,7 +280,8 @@ public static class Builder {
private DataFetcherExceptionHandler defaultExceptionHandler = new SimpleDataFetcherExceptionHandler();
private ExecutionIdProvider idProvider = DEFAULT_EXECUTION_ID_PROVIDER;
private Instrumentation instrumentation = null; // deliberate default here
- private PreparsedDocumentProvider preparsedDocumentProvider = NoOpPreparsedDocumentProvider.INSTANCE;
+ private PreparsedDocumentProvider preparsedDocumentProvider = null;
+ private boolean doNotCacheOperationDocuments = false;
private boolean doNotAutomaticallyDispatchDataLoader = false;
private ValueUnboxer valueUnboxer = ValueUnboxer.DEFAULT;
@@ -325,11 +327,32 @@ public Builder instrumentation(Instrumentation instrumentation) {
return this;
}
+ /**
+ * A {@link PreparsedDocumentProvider} allows you to provide a custom implementation of how
+ * operation documents are retrieved and possibly cached. By default, the inbuilt {@link CachingDocumentProvider}
+ * will be used, but you can replace that with your own implementation
+ *
+ * @param preparsedDocumentProvider the provider to use
+ *
+ * @return this builder
+ */
public Builder preparsedDocumentProvider(PreparsedDocumentProvider preparsedDocumentProvider) {
this.preparsedDocumentProvider = assertNotNull(preparsedDocumentProvider, () -> "PreparsedDocumentProvider must be non null");
return this;
}
+
+ /**
+ * Deactivates the caching of operation documents via the inbuilt {@link graphql.execution.preparsed.caching.CachingDocumentProvider}
+ * If deactivated, no caching will be performed
+ *
+ * @return this builder
+ */
+ public Builder doNotCacheOperationDocuments() {
+ this.doNotCacheOperationDocuments = true;
+ return this;
+ }
+
public Builder executionIdProvider(ExecutionIdProvider executionIdProvider) {
this.idProvider = assertNotNull(executionIdProvider, () -> "ExecutionIdProvider must be non null");
return this;
@@ -367,6 +390,9 @@ public GraphQL build() {
if (instrumentation == null) {
this.instrumentation = SimplePerformantInstrumentation.INSTANCE;
}
+ if (preparsedDocumentProvider == null) {
+ preparsedDocumentProvider = (doNotCacheOperationDocuments ? NoOpPreparsedDocumentProvider.INSTANCE : new CachingDocumentProvider());
+ }
return new GraphQL(this);
}
}
diff --git a/src/main/java/graphql/execution/preparsed/NoOpPreparsedDocumentProvider.java b/src/main/java/graphql/execution/preparsed/NoOpPreparsedDocumentProvider.java
index 03a96776b6..2736adcab8 100644
--- a/src/main/java/graphql/execution/preparsed/NoOpPreparsedDocumentProvider.java
+++ b/src/main/java/graphql/execution/preparsed/NoOpPreparsedDocumentProvider.java
@@ -2,12 +2,17 @@
import graphql.ExecutionInput;
-import graphql.Internal;
+import graphql.PublicApi;
+import org.jspecify.annotations.NullMarked;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
-@Internal
+/**
+ * A {@link PreparsedDocumentProvider that does nothing}
+ */
+@PublicApi
+@NullMarked
public class NoOpPreparsedDocumentProvider implements PreparsedDocumentProvider {
public static final NoOpPreparsedDocumentProvider INSTANCE = new NoOpPreparsedDocumentProvider();
diff --git a/src/main/java/graphql/execution/preparsed/caching/CachingDocumentProvider.java b/src/main/java/graphql/execution/preparsed/caching/CachingDocumentProvider.java
new file mode 100644
index 0000000000..5c4d1e5d92
--- /dev/null
+++ b/src/main/java/graphql/execution/preparsed/caching/CachingDocumentProvider.java
@@ -0,0 +1,68 @@
+package graphql.execution.preparsed.caching;
+
+import com.github.benmanes.caffeine.cache.Caffeine;
+import graphql.ExecutionInput;
+import graphql.PublicApi;
+import graphql.execution.preparsed.PreparsedDocumentEntry;
+import graphql.execution.preparsed.PreparsedDocumentProvider;
+import org.jspecify.annotations.NullMarked;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+
+import static graphql.execution.Async.toCompletableFuture;
+
+/**
+ * The CachingDocumentProvider allows previously parsed and validated operations to be cached and
+ * hence re-used. This can lead to significant time savings, especially for large operations.
+ *
+ * By default, graphql-java will cache the parsed {@link PreparsedDocumentEntry} that represents
+ * a parsed and validated graphql query IF {@link Caffeine} is present on the class path
+ * at runtime. If it's not then no caching takes place.
+ *
+ * You can provide your own {@link DocumentCache} implementation and hence use any cache
+ * technology you like.
+ */
+@PublicApi
+@NullMarked
+public class CachingDocumentProvider implements PreparsedDocumentProvider {
+ private final DocumentCache documentCache;
+
+ /**
+ * By default, it will try to use a {@link Caffeine} backed implementation if it's on the class
+ * path otherwise it will become a non caching mechanism.
+ *
+ * @see CaffeineDocumentCache
+ */
+ public CachingDocumentProvider() {
+ this(new CaffeineDocumentCache());
+ }
+
+ /**
+ * You can use your own cache implementation and provide that to this class to use
+ *
+ * @param documentCache the cache to use
+ */
+ public CachingDocumentProvider(DocumentCache documentCache) {
+ this.documentCache = documentCache;
+ }
+
+ /**
+ * @return the {@link DocumentCache} being used
+ */
+ public DocumentCache getDocumentCache() {
+ return documentCache;
+ }
+
+ @Override
+ public CompletableFuture getDocumentAsync(ExecutionInput executionInput, Function parseAndValidateFunction) {
+ if (documentCache.isNoop()) {
+ // saves creating keys and doing a lookup that will just call this function anyway
+ return toCompletableFuture(parseAndValidateFunction.apply(executionInput));
+ }
+ DocumentCache.DocumentCacheKey cacheKey = new DocumentCache.DocumentCacheKey(executionInput.getQuery(), executionInput.getOperationName(), executionInput.getLocale());
+ Object cacheEntry = documentCache.get(cacheKey, key -> parseAndValidateFunction.apply(executionInput));
+ return toCompletableFuture(cacheEntry);
+ }
+
+}
diff --git a/src/main/java/graphql/execution/preparsed/caching/CaffeineDocumentCache.java b/src/main/java/graphql/execution/preparsed/caching/CaffeineDocumentCache.java
new file mode 100644
index 0000000000..2f918936f2
--- /dev/null
+++ b/src/main/java/graphql/execution/preparsed/caching/CaffeineDocumentCache.java
@@ -0,0 +1,77 @@
+package graphql.execution.preparsed.caching;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import graphql.PublicApi;
+import graphql.execution.preparsed.PreparsedDocumentEntry;
+import graphql.util.ClassKit;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.function.Function;
+
+import static java.util.Objects.requireNonNull;
+
+@PublicApi
+@NullMarked
+public class CaffeineDocumentCache implements DocumentCache {
+
+ private final static boolean isCaffeineAvailable = ClassKit.isClassAvailable("com.github.benmanes.caffeine.cache.Caffeine");
+
+ @Nullable
+ private final Object caffeineCacheObj;
+
+ CaffeineDocumentCache(boolean isCaffeineAvailable) {
+ if (isCaffeineAvailable) {
+ CaffeineDocumentCacheOptions options = CaffeineDocumentCacheOptions.getDefaultJvmOptions();
+ caffeineCacheObj = Caffeine.newBuilder()
+ .expireAfterAccess(options.getExpireAfterAccess())
+ .maximumSize(options.getMaxSize())
+ .build();
+ } else {
+ caffeineCacheObj = null;
+ }
+ }
+
+ /**
+ * Creates a cache that works if Caffeine is on the class path otherwise its
+ * a no op.
+ */
+ public CaffeineDocumentCache() {
+ this(isCaffeineAvailable);
+ }
+
+ /**
+ * If you want to control the {@link Caffeine} configuration, using this constructor and pass in your own {@link Caffeine} cache
+ *
+ * @param caffeineCache the custom {@link Caffeine} cache to use
+ */
+ public CaffeineDocumentCache(Cache caffeineCache) {
+ this.caffeineCacheObj = caffeineCache;
+ }
+
+ @Override
+ public PreparsedDocumentEntry get(DocumentCacheKey key, Function mappingFunction) {
+ if (isNoop()) {
+ return mappingFunction.apply(key);
+ }
+ return cache().get(key, mappingFunction);
+ }
+
+ private Cache cache() {
+ //noinspection unchecked
+ return (Cache) requireNonNull(caffeineCacheObj);
+ }
+
+ @Override
+ public boolean isNoop() {
+ return caffeineCacheObj == null;
+ }
+
+ @Override
+ public void invalidateAll() {
+ if (!isNoop()) {
+ cache().invalidateAll();
+ }
+ }
+}
diff --git a/src/main/java/graphql/execution/preparsed/caching/CaffeineDocumentCacheOptions.java b/src/main/java/graphql/execution/preparsed/caching/CaffeineDocumentCacheOptions.java
new file mode 100644
index 0000000000..ef5a5d6454
--- /dev/null
+++ b/src/main/java/graphql/execution/preparsed/caching/CaffeineDocumentCacheOptions.java
@@ -0,0 +1,85 @@
+package graphql.execution.preparsed.caching;
+
+import graphql.PublicApi;
+import org.jspecify.annotations.NullMarked;
+
+import java.time.Duration;
+
+/**
+ * This controls the default options that get use in the {@link CaffeineDocumentCache} creation
+ */
+@PublicApi
+@NullMarked
+public class CaffeineDocumentCacheOptions {
+
+ /**
+ * By default, we expire documents after 5 minutes if they are not accessed
+ */
+ public static final Duration EXPIRED_AFTER_ACCESS = Duration.ofMinutes(5);
+ /**
+ * By default, we hold 500 operations
+ */
+ public static final int MAX_SIZE = 500;
+
+ private static CaffeineDocumentCacheOptions defaultJvmOptions = newOptions()
+ .expireAfterAccess(EXPIRED_AFTER_ACCESS)
+ .maxSize(MAX_SIZE)
+ .build();
+
+ /**
+ * This returns the JVM wide default options for the {@link CaffeineDocumentCache}
+ *
+ * @return the JVM wide default options
+ */
+ public static CaffeineDocumentCacheOptions getDefaultJvmOptions() {
+ return defaultJvmOptions;
+ }
+
+ /**
+ * This sets new JVM wide default options for the {@link CaffeineDocumentCache}
+ *
+ * @param jvmOptions the options to use
+ */
+ public static void setDefaultJvmOptions(CaffeineDocumentCacheOptions jvmOptions) {
+ defaultJvmOptions = jvmOptions;
+ }
+
+ private final Duration expireAfterAccess;
+ private final int maxSize;
+
+ private CaffeineDocumentCacheOptions(Builder builder) {
+ this.expireAfterAccess = builder.expireAfterAccess;
+ this.maxSize = builder.maxSize;
+ }
+
+ public Duration getExpireAfterAccess() {
+ return expireAfterAccess;
+ }
+
+ public int getMaxSize() {
+ return maxSize;
+ }
+
+ public static Builder newOptions() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ Duration expireAfterAccess = Duration.ofMinutes(5);
+ int maxSize = 1000;
+
+ public Builder maxSize(int maxSize) {
+ this.maxSize = maxSize;
+ return this;
+ }
+
+ public Builder expireAfterAccess(Duration expireAfterAccess) {
+ this.expireAfterAccess = expireAfterAccess;
+ return this;
+ }
+
+ CaffeineDocumentCacheOptions build() {
+ return new CaffeineDocumentCacheOptions(this);
+ }
+ }
+}
diff --git a/src/main/java/graphql/execution/preparsed/caching/DocumentCache.java b/src/main/java/graphql/execution/preparsed/caching/DocumentCache.java
new file mode 100644
index 0000000000..f4231804da
--- /dev/null
+++ b/src/main/java/graphql/execution/preparsed/caching/DocumentCache.java
@@ -0,0 +1,87 @@
+package graphql.execution.preparsed.caching;
+
+import graphql.DuckTyped;
+import graphql.PublicApi;
+import graphql.execution.preparsed.PreparsedDocumentEntry;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Locale;
+import java.util.Objects;
+import java.util.function.Function;
+
+/**
+ * This represents a cache interface to get a document from a cache key. You can use your own cache implementation
+ * to back the caching of parsed graphql documents.
+ */
+@PublicApi
+@NullMarked
+public interface DocumentCache {
+ /**
+ * Called to get a document that has previously been parsed ad validated. The return value of this method
+ * can be either a {@link PreparsedDocumentEntry} or a {@link java.util.concurrent.CompletableFuture} promise
+ * to a {@link PreparsedDocumentEntry}. This allows caches that are in memory to return direct values OR
+ * if the cache is distributed, it can return a promise to a value.
+ *
+ * @param key the cache key
+ * @param mappingFunction if the value is missing in cache this function can be called to create a value
+ *
+ * @return a non-null {@link PreparsedDocumentEntry} or a promise to one via a {@link java.util.concurrent.CompletableFuture}
+ */
+ @DuckTyped(shape = "PreparsedDocumentEntry | CompletableFuture mappingFunction);
+
+ /**
+ * @return true if the cache in fact does no caching otherwise false. This helps the implementation optimise how the cache is used or not.
+ */
+ boolean isNoop();
+
+ /**
+ * Called to clear the cache. If your implementation doesn't support this, then just no op the method
+ */
+ void invalidateAll();
+
+ /**
+ * This represents the key to the document cache
+ */
+ class DocumentCacheKey {
+ private final String query;
+ @Nullable
+ private final String operationName;
+ private final Locale locale;
+
+ DocumentCacheKey(String query, @Nullable String operationName, Locale locale) {
+ this.query = query;
+ this.operationName = operationName;
+ this.locale = locale;
+ }
+
+ public String getQuery() {
+ return query;
+ }
+
+ public @Nullable String getOperationName() {
+ return operationName;
+ }
+
+ public Locale getLocale() {
+ return locale;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ DocumentCacheKey cacheKey = (DocumentCacheKey) o;
+ return Objects.equals(query, cacheKey.query) &&
+ Objects.equals(operationName, cacheKey.operationName) &&
+ Objects.equals(locale, cacheKey.locale);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(query, operationName, locale);
+ }
+ }
+}
diff --git a/src/main/java/graphql/util/ClassKit.java b/src/main/java/graphql/util/ClassKit.java
new file mode 100644
index 0000000000..059622d61b
--- /dev/null
+++ b/src/main/java/graphql/util/ClassKit.java
@@ -0,0 +1,13 @@
+package graphql.util;
+
+public class ClassKit {
+ public static boolean isClassAvailable(String className) {
+ ClassLoader classLoader = ClassKit.class.getClassLoader();
+ Class> caffieneClass = null;
+ try {
+ caffieneClass = classLoader.loadClass(className);
+ } catch (ClassNotFoundException ignored) {
+ }
+ return caffieneClass != null;
+ }
+}
diff --git a/src/test/groovy/graphql/GraphQLTest.groovy b/src/test/groovy/graphql/GraphQLTest.groovy
index 7b9c48ad7d..c806c9330e 100644
--- a/src/test/groovy/graphql/GraphQLTest.groovy
+++ b/src/test/groovy/graphql/GraphQLTest.groovy
@@ -20,6 +20,8 @@ import graphql.execution.instrumentation.InstrumentationState
import graphql.execution.instrumentation.SimplePerformantInstrumentation
import graphql.execution.instrumentation.parameters.InstrumentationCreateStateParameters
import graphql.execution.preparsed.NoOpPreparsedDocumentProvider
+import graphql.execution.preparsed.PreparsedDocumentProvider
+import graphql.execution.preparsed.caching.CachingDocumentProvider
import graphql.language.SourceLocation
import graphql.schema.DataFetcher
import graphql.schema.DataFetchingEnvironment
@@ -1414,7 +1416,7 @@ many lines''']
graphQL.getGraphQLSchema() == StarWarsSchema.starWarsSchema
graphQL.getIdProvider() == ExecutionIdProvider.DEFAULT_EXECUTION_ID_PROVIDER
graphQL.getValueUnboxer() == ValueUnboxer.DEFAULT
- graphQL.getPreparsedDocumentProvider() == NoOpPreparsedDocumentProvider.INSTANCE
+ graphQL.getPreparsedDocumentProvider() instanceof CachingDocumentProvider
graphQL.getInstrumentation() instanceof Instrumentation
graphQL.getQueryStrategy() instanceof AsyncExecutionStrategy
graphQL.getMutationStrategy() instanceof AsyncSerialExecutionStrategy
@@ -1605,4 +1607,26 @@ many lines''']
!er.errors.isEmpty()
er.errors[0].message.contains("Unknown operation named 'X'")
}
+
+ def "caching document provider is default unless they say otherwise"() {
+ when:
+ def graphQL = GraphQL.newGraphQL(StarWarsSchema.starWarsSchema).build()
+ then:
+ graphQL.getPreparsedDocumentProvider() instanceof CachingDocumentProvider
+
+ when:
+ graphQL = GraphQL.newGraphQL(StarWarsSchema.starWarsSchema).doNotCacheOperationDocuments().build()
+ then:
+ graphQL.getPreparsedDocumentProvider() == NoOpPreparsedDocumentProvider.INSTANCE
+
+
+ PreparsedDocumentProvider customProvider = { ei, func -> func.apply(ei) }
+ when:
+ graphQL = GraphQL.newGraphQL(StarWarsSchema.starWarsSchema)
+ .doNotCacheOperationDocuments() // doesnt matter if they provide an implementation
+ .preparsedDocumentProvider(customProvider)
+ .build()
+ then:
+ graphQL.getPreparsedDocumentProvider() == customProvider
+ }
}
diff --git a/src/test/groovy/graphql/ParseAndValidateTest.groovy b/src/test/groovy/graphql/ParseAndValidateTest.groovy
index fa66c3cbed..ea2a3d392b 100644
--- a/src/test/groovy/graphql/ParseAndValidateTest.groovy
+++ b/src/test/groovy/graphql/ParseAndValidateTest.groovy
@@ -1,5 +1,6 @@
package graphql
+import graphql.execution.preparsed.NoOpPreparsedDocumentProvider
import graphql.language.Document
import graphql.language.SourceLocation
import graphql.parser.InvalidSyntaxException
@@ -121,7 +122,9 @@ class ParseAndValidateTest extends Specification {
def "can use the graphql context to stop certain validation rules"() {
def sdl = '''type Query { foo : ID } '''
- def graphQL = TestUtil.graphQL(sdl).build()
+ // if you use validation rule predicates - then you cant use caching
+ def graphQL = TestUtil.graphQL(sdl)
+ .preparsedDocumentProvider(NoOpPreparsedDocumentProvider.INSTANCE).build()
Predicate> predicate = new Predicate>() {
@Override
diff --git a/src/test/groovy/graphql/execution/preparsed/caching/CachingDocumentProviderTest.groovy b/src/test/groovy/graphql/execution/preparsed/caching/CachingDocumentProviderTest.groovy
new file mode 100644
index 0000000000..8fa7553640
--- /dev/null
+++ b/src/test/groovy/graphql/execution/preparsed/caching/CachingDocumentProviderTest.groovy
@@ -0,0 +1,336 @@
+package graphql.execution.preparsed.caching
+
+import com.github.benmanes.caffeine.cache.Caffeine
+import com.github.benmanes.caffeine.cache.Ticker
+import graphql.ExecutionInput
+import graphql.ExecutionResult
+import graphql.GraphQL
+import graphql.StarWarsSchema
+import graphql.execution.preparsed.PreparsedDocumentEntry
+import graphql.parser.Parser
+import spock.lang.Specification
+
+import java.time.Duration
+import java.util.concurrent.CompletableFuture
+import java.util.function.Function
+
+import static graphql.ExecutionInput.newExecutionInput
+import static graphql.execution.preparsed.caching.DocumentCache.DocumentCacheKey
+
+class CachingDocumentProviderTest extends Specification {
+ private String heroQuery1
+
+ void setup() {
+ heroQuery1 = """
+ query HeroNameQuery {
+ hero {
+ name
+ }
+ }
+ """
+ }
+
+ def "basic integration test"() {
+
+ def cachingDocumentProvider = new CachingDocumentProvider()
+ GraphQL graphQL = GraphQL.newGraphQL(StarWarsSchema.starWarsSchema)
+ .preparsedDocumentProvider(cachingDocumentProvider)
+ .build()
+
+ when:
+ def executionInput = newExecutionInput(heroQuery1)
+ .operationName("HeroNameQuery").build()
+
+ def er = graphQL.execute(executionInput)
+
+ then:
+ er.errors.isEmpty()
+ er.data == [hero: [name: "R2-D2"]]
+
+ cachingDocumentProvider.getDocumentCache() instanceof CaffeineDocumentCache
+ }
+
+ def "different outcomes are cached correctly integration test"() {
+
+ def cachingDocumentProvider = new CachingDocumentProvider()
+ GraphQL graphQL = GraphQL.newGraphQL(StarWarsSchema.starWarsSchema)
+ .preparsedDocumentProvider(cachingDocumentProvider)
+ .build()
+
+
+ def query = """
+ query HeroNameQuery {
+ hero {
+ name
+ }
+ }
+ query HeroNameQuery2 {
+ hero {
+ nameAlias : name
+ }
+ }
+ """
+ def invalidQuery = """
+ query InvalidQuery {
+ hero {
+ nameX
+ }
+ }
+ """
+ when:
+ def ei = newExecutionInput(query).operationName("HeroNameQuery").build()
+ def er = graphQL.execute(ei)
+
+ then:
+ er.errors.isEmpty()
+ er.data == [hero: [name: "R2-D2"]]
+
+ when:
+ ei = newExecutionInput(query).operationName("HeroNameQuery2").build()
+ er = graphQL.execute(ei)
+
+ then:
+ er.errors.isEmpty()
+ er.data == [hero: [nameAlias: "R2-D2"]]
+
+ when:
+ ei = newExecutionInput(invalidQuery).operationName("InvalidQuery").build()
+ er = graphQL.execute(ei)
+
+ then:
+ !er.errors.isEmpty()
+ er.errors[0].message == "Validation error (FieldUndefined@[hero/nameX]) : Field 'nameX' in type 'Character' is undefined"
+
+ // now do them all again and they are cached but the outcome is the same
+
+ when:
+ ei = newExecutionInput(query).operationName("HeroNameQuery").build()
+ er = graphQL.execute(ei)
+
+ then:
+ er.errors.isEmpty()
+ er.data == [hero: [name: "R2-D2"]]
+
+ when:
+ ei = newExecutionInput(query).operationName("HeroNameQuery2").build()
+ er = graphQL.execute(ei)
+
+ then:
+ er.errors.isEmpty()
+ er.data == [hero: [nameAlias: "R2-D2"]]
+
+ when:
+ ei = newExecutionInput(invalidQuery).operationName("InvalidQuery").build()
+ er = graphQL.execute(ei)
+
+ then:
+ !er.errors.isEmpty()
+ er.errors[0].message == "Validation error (FieldUndefined@[hero/nameX]) : Field 'nameX' in type 'Character' is undefined"
+ }
+
+ def "integration still works when caffeine is not on the class path"() {
+ when:
+ // we fake out the test here saying its NOT on the classpath
+ def cache = new CaffeineDocumentCache(false)
+ def cachingDocumentProvider = new CachingDocumentProvider(cache)
+ GraphQL graphQL = GraphQL.newGraphQL(StarWarsSchema.starWarsSchema)
+ .preparsedDocumentProvider(cachingDocumentProvider)
+ .build()
+ def executionInput = newExecutionInput(heroQuery1)
+ .operationName("HeroNameQuery").build()
+
+ ExecutionResult er = null
+ for (int i = 0; i < count; i++) {
+ er = graphQL.execute(executionInput)
+ assert er.data == [hero: [name: "R2-D2"]]
+ }
+
+
+ then:
+ er.errors.isEmpty()
+ er.data == [hero: [name: "R2-D2"]]
+
+ where:
+ count || _
+ 1 || _
+ 5 || _
+ }
+
+ def "integration of a custom cache"() {
+ when:
+
+ def cache = new DocumentCache() {
+ // not really useful in production since its unbounded
+ def map = new HashMap()
+
+ @Override
+ Object get(DocumentCacheKey key, Function mappingFunction) {
+ // we can have async values
+ return CompletableFuture.supplyAsync {
+ return map.computeIfAbsent(key, mappingFunction)
+ }
+ }
+
+ @Override
+ boolean isNoop() {
+ return false
+ }
+
+ @Override
+ void invalidateAll() {
+ map.clear()
+ }
+ }
+ // a custom cache in play
+ def cachingDocumentProvider = new CachingDocumentProvider(cache)
+ GraphQL graphQL = GraphQL.newGraphQL(StarWarsSchema.starWarsSchema)
+ .preparsedDocumentProvider(cachingDocumentProvider)
+ .build()
+ def executionInput = newExecutionInput(heroQuery1)
+ .operationName("HeroNameQuery").build()
+
+ ExecutionResult er = null
+ for (int i = 0; i < count; i++) {
+ er = graphQL.execute(executionInput)
+ assert er.data == [hero: [name: "R2-D2"]]
+ }
+
+
+ then:
+ er.errors.isEmpty()
+ er.data == [hero: [name: "R2-D2"]]
+
+ where:
+ count || _
+ 1 || _
+ 5 || _
+ }
+
+ class CountingDocProvider implements Function {
+ int count = 0
+
+ @Override
+ PreparsedDocumentEntry apply(ExecutionInput executionInput) {
+ count++
+ def document = Parser.parse(executionInput.query)
+ return new PreparsedDocumentEntry(document)
+ }
+ }
+
+ def "caching happens and the parse and validated function is avoided"() {
+ def cache = new CaffeineDocumentCache(true)
+ def cachingDocumentProvider = new CachingDocumentProvider(cache)
+
+ def ei = newExecutionInput("query q { f }").build()
+ def callback = new CountingDocProvider()
+
+ when:
+ def documentEntry = cachingDocumentProvider.getDocumentAsync(ei, callback).join()
+
+ then:
+ !documentEntry.hasErrors()
+ documentEntry.document != null
+ callback.count == 1
+
+ when:
+ documentEntry = cachingDocumentProvider.getDocumentAsync(ei, callback).join()
+
+ then:
+ !documentEntry.hasErrors()
+ documentEntry.document != null
+ // cached
+ callback.count == 1
+
+ when:
+ cache.invalidateAll()
+ documentEntry = cachingDocumentProvider.getDocumentAsync(ei, callback).join()
+
+ then:
+ !documentEntry.hasErrors()
+ documentEntry.document != null
+ // after cleared cached
+ callback.count == 2
+
+ }
+
+ def "when caching is not present then parse and validated function is always called"() {
+ def cache = new CaffeineDocumentCache(false)
+ def cachingDocumentProvider = new CachingDocumentProvider(cache)
+
+ def ei = newExecutionInput("query q { f }").build()
+ def callback = new CountingDocProvider()
+
+ when:
+ def documentEntry = cachingDocumentProvider.getDocumentAsync(ei, callback).join()
+
+ then:
+ !documentEntry.hasErrors()
+ documentEntry.document != null
+ callback.count == 1
+
+ when:
+ documentEntry = cachingDocumentProvider.getDocumentAsync(ei, callback).join()
+
+ then:
+ !documentEntry.hasErrors()
+ documentEntry.document != null
+ // not cached
+ callback.count == 2
+
+ when:
+ cache.invalidateAll()
+ documentEntry = cachingDocumentProvider.getDocumentAsync(ei, callback).join()
+
+ then:
+ !documentEntry.hasErrors()
+ documentEntry.document != null
+ callback.count == 3
+ }
+
+ def "time can pass and entries can expire and the code handles that"() {
+ long nanoTime = 0
+ Ticker ticker = { return nanoTime }
+ def caffeineCache = Caffeine.newBuilder()
+ .ticker(ticker)
+ .expireAfterAccess(Duration.ofMinutes(2))
+ . build()
+ def documentCache = new CaffeineDocumentCache(caffeineCache)
+
+ // note this is a custom caffeine instance pass in
+ def cachingDocumentProvider = new CachingDocumentProvider(documentCache)
+
+ def ei = newExecutionInput("query q { f }").build()
+ def callback = new CountingDocProvider()
+
+ when:
+ def documentEntry = cachingDocumentProvider.getDocumentAsync(ei, callback).join()
+
+ then:
+ !documentEntry.hasErrors()
+ documentEntry.document != null
+ callback.count == 1
+
+ when:
+ documentEntry = cachingDocumentProvider.getDocumentAsync(ei, callback).join()
+
+ then:
+ !documentEntry.hasErrors()
+ documentEntry.document != null
+ callback.count == 1
+
+ when:
+ //
+ // this is kinda testing Caffeine but I am also trying to make sure that th wrapper
+ // code does the mappingFunction if its expired
+ //
+ // advance time
+ //
+ nanoTime += Duration.ofMinutes(5).toNanos()
+ documentEntry = cachingDocumentProvider.getDocumentAsync(ei, callback).join()
+
+ then:
+ !documentEntry.hasErrors()
+ documentEntry.document != null
+ callback.count == 2
+ }
+}