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 + } +}