Skip to content

Commit 4a893b0

Browse files
authored
Persisted query support (#2013)
1 parent 8e55666 commit 4a893b0

15 files changed

+511
-27
lines changed

src/main/java/graphql/ExecutionInput.java

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,26 @@ public class ExecutionInput {
2424
private final Object localContext;
2525
private final Object root;
2626
private final Map<String, Object> variables;
27+
private final Map<String, Object> extensions;
2728
private final DataLoaderRegistry dataLoaderRegistry;
2829
private final CacheControl cacheControl;
2930
private final ExecutionId executionId;
3031
private final Locale locale;
3132

3233

3334
@Internal
34-
private ExecutionInput(String query, String operationName, Object context, Object root, Map<String, Object> variables, DataLoaderRegistry dataLoaderRegistry, CacheControl cacheControl, ExecutionId executionId, Locale locale, Object localContext) {
35-
this.query = assertNotNull(query, () -> "query can't be null");
36-
this.operationName = operationName;
37-
this.context = context;
38-
this.root = root;
39-
this.variables = variables;
40-
this.dataLoaderRegistry = dataLoaderRegistry;
41-
this.cacheControl = cacheControl;
42-
this.executionId = executionId;
43-
this.locale = locale;
44-
this.localContext = localContext;
35+
private ExecutionInput(Builder builder) {
36+
this.query = assertNotNull(builder.query, () -> "query can't be null");
37+
this.operationName = builder.operationName;
38+
this.context = builder.context;
39+
this.root = builder.root;
40+
this.variables = builder.variables;
41+
this.dataLoaderRegistry = builder.dataLoaderRegistry;
42+
this.cacheControl = builder.cacheControl;
43+
this.executionId = builder.executionId;
44+
this.locale = builder.locale;
45+
this.localContext = builder.localContext;
46+
this.extensions = builder.extensions;
4547
}
4648

4749
/**
@@ -64,6 +66,7 @@ public String getOperationName() {
6466
public Object getContext() {
6567
return context;
6668
}
69+
6770
/**
6871
* @return the local context object to pass to all top level (i.e. query, mutation, subscription) data fetchers
6972
*/
@@ -115,6 +118,13 @@ public Locale getLocale() {
115118
return locale;
116119
}
117120

121+
/**
122+
* @return a map of extension values that can be sent in to a request
123+
*/
124+
public Map<String, Object> getExtensions() {
125+
return extensions;
126+
}
127+
118128
/**
119129
* This helps you transform the current ExecutionInput object into another one by starting a builder with all
120130
* the current values and allows you to transform it how you want.
@@ -132,6 +142,7 @@ public ExecutionInput transform(Consumer<Builder> builderConsumer) {
132142
.dataLoaderRegistry(this.dataLoaderRegistry)
133143
.cacheControl(this.cacheControl)
134144
.variables(this.variables)
145+
.extensions(this.extensions)
135146
.executionId(this.executionId)
136147
.locale(this.locale);
137148

@@ -179,6 +190,7 @@ public static class Builder {
179190
private Object localContext;
180191
private Object root;
181192
private Map<String, Object> variables = Collections.emptyMap();
193+
public Map<String, Object> extensions = Collections.emptyMap();
182194
//
183195
// this is important - it allows code to later known if we never really set a dataloader and hence it can optimize
184196
// dataloader field tracking away.
@@ -214,7 +226,6 @@ public Builder executionId(ExecutionId executionId) {
214226
* Sets the locale to use for this operation
215227
*
216228
* @param locale the locale to use
217-
*
218229
* @return this builder
219230
*/
220231
public Builder locale(Locale locale) {
@@ -224,6 +235,7 @@ public Builder locale(Locale locale) {
224235

225236
/**
226237
* Sets initial localContext in root data fetchers
238+
*
227239
* @return this builder
228240
*/
229241
public Builder localContext(Object localContext) {
@@ -263,6 +275,11 @@ public Builder variables(Map<String, Object> variables) {
263275
return this;
264276
}
265277

278+
public Builder extensions(Map<String, Object> extensions) {
279+
this.extensions = assertNotNull(extensions, () -> "extensions map can't be null");
280+
return this;
281+
}
282+
266283
/**
267284
* You should create new {@link org.dataloader.DataLoaderRegistry}s and new {@link org.dataloader.DataLoader}s for each execution. Do not
268285
* re-use
@@ -282,7 +299,7 @@ public Builder cacheControl(CacheControl cacheControl) {
282299
}
283300

284301
public ExecutionInput build() {
285-
return new ExecutionInput(query, operationName, context, root, variables, dataLoaderRegistry, cacheControl, executionId, locale, localContext);
302+
return new ExecutionInput(this);
286303
}
287304
}
288305
}

src/main/java/graphql/execution/preparsed/NoOpPreparsedDocumentProvider.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public class NoOpPreparsedDocumentProvider implements PreparsedDocumentProvider
1111
public static final NoOpPreparsedDocumentProvider INSTANCE = new NoOpPreparsedDocumentProvider();
1212

1313
@Override
14-
public PreparsedDocumentEntry getDocument(ExecutionInput executionInput, Function<ExecutionInput, PreparsedDocumentEntry> computeFunction) {
15-
return computeFunction.apply(executionInput);
14+
public PreparsedDocumentEntry getDocument(ExecutionInput executionInput, Function<ExecutionInput, PreparsedDocumentEntry> parseAndValidateFunction) {
15+
return parseAndValidateFunction.apply(executionInput);
1616
}
1717
}

src/main/java/graphql/execution/preparsed/PreparsedDocumentEntry.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
/**
1414
* An instance of a preparsed document entry represents the result of a query parse and validation, like
15-
* an either implementation it contains either the correct result in th document property or the errors.
15+
* an either implementation it contains either the correct result in the document property or the errors.
1616
*
1717
* NOTE: This class implements {@link java.io.Serializable} and hence it can be serialised and placed into a distributed cache. However we
1818
* are not aiming to provide long term compatibility and do not intend for you to place this serialised data into permanent storage,

src/main/java/graphql/execution/preparsed/PreparsedDocumentProvider.java

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,23 @@
77
import java.util.function.Function;
88

99
/**
10-
* Interface that allows clients to hook in Document caching and/or the whitelisting of queries
10+
* Interface that allows clients to hook in Document caching and/or the whitelisting of queries.
1111
*/
1212
@PublicSpi
1313
public interface PreparsedDocumentProvider {
1414
/**
15-
* This is called to get a "cached" pre-parsed query and if its not present, then the computeFunction
16-
* can be called to parse and validate the query
17-
*
18-
* @param executionInput The {@link graphql.ExecutionInput} containing the query
19-
* @param computeFunction If the query has not be pre-parsed, this function can be called to parse it
15+
* This is called to get a "cached" pre-parsed query and if its not present, then the "parseAndValidateFunction"
16+
* can be called to parse and validate the query.
17+
* <p>
18+
* Note - the "parseAndValidateFunction" MUST be called if you dont have a per parsed version of the query because it not only parses
19+
* and validates the query, it invokes {@link graphql.execution.instrumentation.Instrumentation} calls as well for parsing and validation.
20+
* if you dont make a call back on this then these wont happen.
2021
*
22+
* @param executionInput The {@link graphql.ExecutionInput} containing the query
23+
* @param parseAndValidateFunction If the query has not be pre-parsed, this function MUST be called to parse and validate it
2124
* @return an instance of {@link PreparsedDocumentEntry}
2225
*/
23-
PreparsedDocumentEntry getDocument(ExecutionInput executionInput, Function<ExecutionInput, PreparsedDocumentEntry> computeFunction);
26+
PreparsedDocumentEntry getDocument(ExecutionInput executionInput, Function<ExecutionInput, PreparsedDocumentEntry> parseAndValidateFunction);
2427
}
2528

2629

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package graphql.execution.preparsed.persisted;
2+
3+
import graphql.ExecutionInput;
4+
import graphql.PublicApi;
5+
6+
import java.util.Map;
7+
import java.util.Optional;
8+
9+
/**
10+
* This persisted query support class supports the Apollo scheme where the persisted
11+
* query id is in {@link graphql.ExecutionInput#getExtensions()}.
12+
* <p>
13+
* You need to provide a {@link PersistedQueryCache} cache implementation
14+
* as the backing cache.
15+
* <p>
16+
* See <a href="https://www.apollographql.com/docs/apollo-server/performance/apq/">Apollo Persisted Queries</a>
17+
* <p>
18+
* The Apollo client sends a hash of the persisted query in the input extensions in the following form
19+
* <p>
20+
* <pre>
21+
* {
22+
* "extensions":{
23+
* "persistedQuery":{
24+
* "version":1,
25+
* "sha256Hash":"fcf31818e50ac3e818ca4bdbc433d6ab73176f0b9d5f9d5ad17e200cdab6fba4"
26+
* }
27+
* }
28+
* }
29+
* </pre>
30+
*
31+
* @see graphql.ExecutionInput#getExtensions()
32+
*/
33+
@PublicApi
34+
public class ApolloPersistedQuerySupport extends PersistedQuerySupport {
35+
36+
public ApolloPersistedQuerySupport(PersistedQueryCache persistedQueryCache) {
37+
super(persistedQueryCache);
38+
}
39+
40+
@SuppressWarnings("unchecked")
41+
@Override
42+
protected Optional<Object> getPersistedQueryId(ExecutionInput executionInput) {
43+
Map<String, Object> extensions = executionInput.getExtensions();
44+
Map<String, Object> persistedQuery = (Map<String, Object>) extensions.get("persistedQuery");
45+
if (persistedQuery != null) {
46+
Object sha256Hash = persistedQuery.get("sha256Hash");
47+
return Optional.ofNullable(sha256Hash);
48+
}
49+
return Optional.empty();
50+
}
51+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package graphql.execution.preparsed.persisted;
2+
3+
import graphql.Assert;
4+
import graphql.ExecutionInput;
5+
import graphql.PublicApi;
6+
import graphql.execution.preparsed.PreparsedDocumentEntry;
7+
8+
import java.util.HashMap;
9+
import java.util.Map;
10+
import java.util.concurrent.ConcurrentHashMap;
11+
12+
/**
13+
* A PersistedQueryCache that is just an in memory map of known queries.
14+
*/
15+
@PublicApi
16+
public class InMemoryPersistedQueryCache implements PersistedQueryCache {
17+
18+
private final Map<Object, PreparsedDocumentEntry> cache = new ConcurrentHashMap<>();
19+
private final Map<Object, String> knownQueries;
20+
21+
public InMemoryPersistedQueryCache(Map<Object, String> knownQueries) {
22+
this.knownQueries = Assert.assertNotNull(knownQueries);
23+
}
24+
25+
public Map<Object, String> getKnownQueries() {
26+
return knownQueries;
27+
}
28+
29+
@Override
30+
public PreparsedDocumentEntry getPersistedQueryDocument(Object persistedQueryId, ExecutionInput executionInput, PersistedQueryCacheMiss onCacheMiss) throws PersistedQueryNotFound {
31+
return cache.compute(persistedQueryId, (k, v) -> {
32+
if (v != null) {
33+
return v;
34+
}
35+
String queryText = knownQueries.get(persistedQueryId);
36+
if (queryText == null) {
37+
throw new PersistedQueryNotFound(persistedQueryId);
38+
}
39+
return onCacheMiss.apply(queryText);
40+
});
41+
}
42+
43+
public static Builder newInMemoryPersistedQueryCache() {
44+
return new Builder();
45+
}
46+
47+
public static class Builder {
48+
private final Map<Object, String> knownQueries = new HashMap<>();
49+
50+
public Builder addQuery(Object key, String queryText) {
51+
knownQueries.put(key, queryText);
52+
return this;
53+
}
54+
55+
public InMemoryPersistedQueryCache build() {
56+
return new InMemoryPersistedQueryCache(knownQueries);
57+
}
58+
}
59+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package graphql.execution.preparsed.persisted;
2+
3+
import graphql.ExecutionInput;
4+
import graphql.PublicSpi;
5+
import graphql.execution.preparsed.PreparsedDocumentEntry;
6+
7+
/**
8+
* This interface is used to abstract an actual cache that can cache parsed persistent queries.
9+
*/
10+
@PublicSpi
11+
public interface PersistedQueryCache {
12+
13+
/**
14+
* This is called to get a persisted query from cache.
15+
* <p>
16+
* If its present in cache then it must return a PreparsedDocumentEntry where {@link graphql.execution.preparsed.PreparsedDocumentEntry#getDocument()}
17+
* is already parsed and validated. This will be passed onto the graphql engine as is.
18+
* <p>
19+
* If its a valid query id but its no present in cache, (cache miss) then you need to call back the "onCacheMiss" function with associated query text.
20+
* This will be compiled and validated by the graphql engine and the a PreparsedDocumentEntry will be passed back ready for you to cache it.
21+
* <p>
22+
* If its not a valid query id then throw a {@link graphql.execution.preparsed.persisted.PersistedQueryNotFound} to indicate this.
23+
*
24+
* @param persistedQueryId the persisted query id
25+
* @param executionInput the original execution input
26+
* @param onCacheMiss the call back should it be a valid query id but its not currently not in the cache
27+
* @return a parsed and validated PreparsedDocumentEntry where {@link graphql.execution.preparsed.PreparsedDocumentEntry#getDocument()} is set
28+
* @throws graphql.execution.preparsed.persisted.PersistedQueryNotFound if the query id is not know at all and you have no query text
29+
*/
30+
PreparsedDocumentEntry getPersistedQueryDocument(Object persistedQueryId, ExecutionInput executionInput, PersistedQueryCacheMiss onCacheMiss) throws PersistedQueryNotFound;
31+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package graphql.execution.preparsed.persisted;
2+
3+
import graphql.PublicApi;
4+
import graphql.execution.preparsed.PreparsedDocumentEntry;
5+
6+
import java.util.function.Function;
7+
8+
/**
9+
* The call back when a valid persisted query is not in cache and it needs to be compiled and validated
10+
* by the graphql engine. If you get a cache miss in your {@link graphql.execution.preparsed.persisted.PersistedQueryCache} implementation
11+
* then you are required to call back on the provided instance of this interface
12+
*/
13+
@PublicApi
14+
public interface PersistedQueryCacheMiss extends Function<String, PreparsedDocumentEntry> {
15+
/**
16+
* You give back the missing query text and graphql-java will compile and validate it.
17+
*
18+
* @param queryToBeParsedAndValidated the query text to be parsed and validated
19+
* @return a parsed and validated query document ready for caching
20+
*/
21+
@Override
22+
PreparsedDocumentEntry apply(String queryToBeParsedAndValidated);
23+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package graphql.execution.preparsed.persisted;
2+
3+
import graphql.ErrorClassification;
4+
import graphql.PublicApi;
5+
6+
import java.util.LinkedHashMap;
7+
import java.util.Map;
8+
9+
/**
10+
* An exception that indicates the query id is not valid and can be found ever in cache
11+
*/
12+
@PublicApi
13+
public class PersistedQueryNotFound extends RuntimeException implements ErrorClassification {
14+
private final Object persistedQueryId;
15+
16+
public PersistedQueryNotFound(Object persistedQueryId) {
17+
this.persistedQueryId = persistedQueryId;
18+
}
19+
20+
@Override
21+
public String getMessage() {
22+
return "PersistedQueryNotFound";
23+
}
24+
25+
public Object getPersistedQueryId() {
26+
return persistedQueryId;
27+
}
28+
29+
@Override
30+
public String toString() {
31+
return "PersistedQueryNotFound";
32+
}
33+
34+
public Map<String, Object> getExtensions() {
35+
LinkedHashMap<String, Object> extensions = new LinkedHashMap<>();
36+
extensions.put("persistedQueryId", persistedQueryId);
37+
extensions.put("generatedBy", "graphql-java");
38+
return extensions;
39+
}
40+
}

0 commit comments

Comments
 (0)