Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions src/main/java/graphql/ExecutionInput.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import graphql.collect.ImmutableKit;
import graphql.execution.ExecutionId;
import graphql.execution.RawVariables;
import graphql.execution.preparsed.persisted.PersistedQuerySupport;
import org.dataloader.DataLoaderRegistry;

import java.util.Locale;
Expand Down Expand Up @@ -31,10 +32,19 @@ public class ExecutionInput {
private final Locale locale;
private final AtomicBoolean cancelled;

/**
* In order for {@link #getQuery()} to never be null, use this to mark
* them so that invariant can be satisfied while assuming that the persisted query
* id is elsewhere.
*/
public final static String PERSISTED_QUERY_MARKER = PersistedQuerySupport.PERSISTED_QUERY_MARKER;

private final static String APOLLO_AUTOMATIC_PERSISTED_QUERY_EXTENSION = "persistedQuery";


@Internal
private ExecutionInput(Builder builder) {
this.query = assertNotNull(builder.query, () -> "query can't be null");
this.query = assertQuery(builder);
this.operationName = builder.operationName;
this.context = builder.context;
this.graphQLContext = assertNotNull(builder.graphQLContext);
Expand All @@ -48,6 +58,30 @@ private ExecutionInput(Builder builder) {
this.cancelled = builder.cancelled;
}

private static String assertQuery(Builder builder) {
if ((builder.query == null || builder.query.isEmpty()) && isPersistedQuery(builder)) {
return PERSISTED_QUERY_MARKER;
}

return assertNotNull(builder.query, () -> "query can't be null");
}

/**
* This is used to determine if this execution input is a persisted query or not.
*
* @implNote The current implementation supports Apollo Persisted Queries (APQ) by checking for
* the extensions property for the persisted query extension.
* See <a href="https://www.apollographql.com/docs/apollo-server/performance/apq/">Apollo Persisted Queries</a> for more details.
*
* @param builder the builder to check
*
* @return true if this is a persisted query
*/
private static boolean isPersistedQuery(Builder builder) {
return builder.extensions != null &&
builder.extensions.containsKey(APOLLO_AUTOMATIC_PERSISTED_QUERY_EXTENSION);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice code


/**
* @return the query text
*/
Expand Down Expand Up @@ -252,7 +286,7 @@ GraphQLContext graphQLContext() {
}

public Builder query(String query) {
this.query = assertNotNull(query, () -> "query can't be null");
this.query = query;
return this;
}

Expand Down Expand Up @@ -312,7 +346,7 @@ public Builder context(Object context) {
return this;
}

/**
/**
* This will give you a builder of {@link GraphQLContext} and any values you set will be copied
* into the underlying {@link GraphQLContext} of this execution input
*
Expand Down Expand Up @@ -393,4 +427,4 @@ public ExecutionInput build() {
return new ExecutionInput(this);
}
}
}
}
43 changes: 43 additions & 0 deletions src/test/groovy/graphql/ExecutionInputTest.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import graphql.execution.instrumentation.InstrumentationState
import graphql.execution.instrumentation.parameters.InstrumentationExecutionStrategyParameters
import graphql.execution.instrumentation.parameters.InstrumentationFieldCompleteParameters
import graphql.execution.instrumentation.parameters.InstrumentationFieldParameters
import graphql.execution.preparsed.persisted.PersistedQuerySupport
import graphql.schema.DataFetcher
import graphql.schema.DataFetchingEnvironment
import org.dataloader.DataLoaderRegistry
Expand Down Expand Up @@ -45,6 +46,21 @@ class ExecutionInputTest extends Specification {
executionInput.query == query
executionInput.locale == Locale.GERMAN
executionInput.extensions == [some: "map"]
executionInput.toString() != null
}

def "build without locale"() {
when:
def executionInput = ExecutionInput.newExecutionInput().query(query)
.dataLoaderRegistry(registry)
.variables(variables)
.root(root)
.graphQLContext({ it.of(["a": "b"]) })
.locale(null)
.extensions([some: "map"])
.build()
then:
executionInput.locale == Locale.getDefault()
}

def "map context build works"() {
Expand Down Expand Up @@ -314,6 +330,33 @@ class ExecutionInputTest extends Specification {
"1000 ms" | plusOrMinus(1000)
}

def "uses persisted query marker when query is null"() {
when:
ExecutionInput.newExecutionInput().query(null).build()
then:
thrown(AssertException)
}

def "uses persisted query marker when query is null and extensions contains persistedQuery"() {
when:
def executionInput = ExecutionInput.newExecutionInput()
.extensions([persistedQuery: "any"])
.query(null)
.build()
then:
executionInput.query == PersistedQuerySupport.PERSISTED_QUERY_MARKER
}

def "uses persisted query marker when query is empty and extensions contains persistedQuery"() {
when:
def executionInput = ExecutionInput.newExecutionInput()
.extensions([persistedQuery: "any"])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the question then is

should we provide a helper builder method (that sets the query for you to the magic value) or use a "map entry" in extentions.

I feel like the former is better but would love to why you chose "extensions" - was there a client reason ?

Copy link
Contributor Author

@timward60 timward60 Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fix was originally from a colleague to unblock our production usage. The idea behind making it implicit in ExecutionInput is that that it would ensure that framework extensions like GraphQL kickstarter, DGS, etc don't need to handle it explicitely as most persisted query implementations today are passed via extensions.

Looking into it further it seems that GraphQL-Java abstracts Persisted Queries which makes sense given there are a few implementations in practice (Apollo APQ, etc). This makes this change not ideal as it is tailored specifially for Apollo APQ implementation.

Based on that I think we some how need to incorporate the check to leverage the PersistedQuerySupport implementation, for example: isPersistedQuery(ExecutionInput input). Ideally instead of just providing a PreparsedDocumentProvider, consumers would explictely pass in the PersistedQuerySupport implementation in addition to the PreparsedDocumentProvider.

If that was implemented as of today, then we would need to remove the assertions in the ExecutionInput that query is not null, and defer that check to later if persisted queries are not enabled assert(query != null).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late reply - I have been away and havent had time to reply.

I think I now understand better - basically this fix allows people who use Apollo PQ (marker value in input extensions)

https://www.apollographql.com/docs/apollo-server/performance/apq

curl --get http://localhost:4000/graphql \
  --header 'content-type: application/json' \
  --data-urlencode 'extensions={"persistedQuery":{"version":1,"sha256Hash":"ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"}}'

if we take this fix it makes it a smidge easier to use APQ - fair enough - but it only works for APQ

At Atlassian for example we used path paramaeter eg /graphql/pq/ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38

And hence we still needed code to make the "query non null" which I dont mind because this ensures that its not accidental opt in to some empty query and blow up later.

And you are right - PQ do not work outn of box from graphql-java - some one must put in a PreparsedDocumentProvider say - APQ support is but one type of PreparsedDocumentProvider and so requiring some one to opt in and be specific is ok.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And hence to this code - leave it as it is

.query("")
.build()
then:
executionInput.query == PersistedQuerySupport.PERSISTED_QUERY_MARKER
}

def "can cancel at specific places"() {
def sdl = '''
type Query {
Expand Down