Skip to content

ResponseMapFactory #3857 #3894

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 6, 2025
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
14 changes: 11 additions & 3 deletions src/main/java/graphql/GraphQL.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import graphql.execution.ExecutionId;
import graphql.execution.ExecutionIdProvider;
import graphql.execution.ExecutionStrategy;
import graphql.execution.ResponseMapFactory;
import graphql.execution.SimpleDataFetcherExceptionHandler;
import graphql.execution.SubscriptionExecutionStrategy;
import graphql.execution.ValueUnboxer;
Expand Down Expand Up @@ -64,7 +65,7 @@
* </li>
*
* <li>{@link graphql.execution.UnresolvedTypeException} - is thrown if a {@link graphql.schema.TypeResolver} fails to provide a concrete
* object type given a interface or union type.
* object type given an interface or union type.
* </li>
*
* <li>{@link graphql.schema.validation.InvalidSchemaException} - is thrown if the schema is not valid when built via
Expand Down Expand Up @@ -92,6 +93,7 @@ public class GraphQL {
private final Instrumentation instrumentation;
private final PreparsedDocumentProvider preparsedDocumentProvider;
private final ValueUnboxer valueUnboxer;
private final ResponseMapFactory responseMapFactory;
private final boolean doNotAutomaticallyDispatchDataLoader;


Expand All @@ -104,6 +106,7 @@ private GraphQL(Builder builder) {
this.instrumentation = assertNotNull(builder.instrumentation, () -> "instrumentation must not be null");
this.preparsedDocumentProvider = assertNotNull(builder.preparsedDocumentProvider, () -> "preparsedDocumentProvider must be non null");
this.valueUnboxer = assertNotNull(builder.valueUnboxer, () -> "valueUnboxer must not be null");
this.responseMapFactory = assertNotNull(builder.responseMapFactory, () -> "responseMapFactory must be not null");
this.doNotAutomaticallyDispatchDataLoader = builder.doNotAutomaticallyDispatchDataLoader;
}

Expand Down Expand Up @@ -213,7 +216,7 @@ public static class Builder {
private PreparsedDocumentProvider preparsedDocumentProvider = NoOpPreparsedDocumentProvider.INSTANCE;
private boolean doNotAutomaticallyDispatchDataLoader = false;
private ValueUnboxer valueUnboxer = ValueUnboxer.DEFAULT;

private ResponseMapFactory responseMapFactory = ResponseMapFactory.DEFAULT;

public Builder(GraphQLSchema graphQLSchema) {
this.graphQLSchema = graphQLSchema;
Expand Down Expand Up @@ -284,6 +287,11 @@ public Builder valueUnboxer(ValueUnboxer valueUnboxer) {
return this;
}

public Builder responseMapFactory(ResponseMapFactory responseMapFactory) {
this.responseMapFactory = responseMapFactory;
return this;
}

public GraphQL build() {
// we use the data fetcher exception handler unless they set their own strategy in which case bets are off
if (queryExecutionStrategy == null) {
Expand Down Expand Up @@ -543,7 +551,7 @@ private CompletableFuture<ExecutionResult> execute(ExecutionInput executionInput
EngineRunningState engineRunningState
) {

Execution execution = new Execution(queryStrategy, mutationStrategy, subscriptionStrategy, instrumentation, valueUnboxer, doNotAutomaticallyDispatchDataLoader);
Execution execution = new Execution(queryStrategy, mutationStrategy, subscriptionStrategy, instrumentation, valueUnboxer, responseMapFactory, doNotAutomaticallyDispatchDataLoader);
ExecutionId executionId = executionInput.getExecutionId();

return execution.execute(document, graphQLSchema, executionId, executionInput, instrumentationState, engineRunningState);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package graphql.execution;

import com.google.common.collect.Maps;
import graphql.ExecutionResult;
import graphql.ExecutionResultImpl;
import graphql.PublicSpi;
Expand Down Expand Up @@ -28,12 +27,7 @@ protected BiConsumer<List<Object>, Throwable> handleResults(ExecutionContext exe
return;
}

Map<String, Object> resolvedValuesByField = Maps.newLinkedHashMapWithExpectedSize(fieldNames.size());
int ix = 0;
for (Object result : results) {
String fieldName = fieldNames.get(ix++);
resolvedValuesByField.put(fieldName, result);
}
Map<String, Object> resolvedValuesByField = executionContext.getResponseMapFactory().createInsertionOrdered(fieldNames, results);
overallResult.complete(new ExecutionResultImpl(resolvedValuesByField, executionContext.getErrors()));
};
}
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/graphql/execution/DefaultResponseMapFactory.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package graphql.execution;

import com.google.common.collect.Maps;
import graphql.Internal;

import java.util.List;
import java.util.Map;

/**
* Implements the contract of {@link ResponseMapFactory} with {@link java.util.LinkedHashMap}.
* This is the default of graphql-java since a long time and changing it could cause breaking changes.
*/
@Internal
public class DefaultResponseMapFactory implements ResponseMapFactory {

@Override
public Map<String, Object> createInsertionOrdered(List<String> keys, List<Object> values) {
Map<String, Object> result = Maps.newLinkedHashMapWithExpectedSize(keys.size());
int ix = 0;
for (Object fieldValue : values) {
String fieldName = keys.get(ix++);
result.put(fieldName, fieldValue);
}
return result;
}
}
4 changes: 4 additions & 0 deletions src/main/java/graphql/execution/Execution.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,22 @@ public class Execution {
private final ExecutionStrategy subscriptionStrategy;
private final Instrumentation instrumentation;
private final ValueUnboxer valueUnboxer;
private final ResponseMapFactory responseMapFactory;
private final boolean doNotAutomaticallyDispatchDataLoader;

public Execution(ExecutionStrategy queryStrategy,
ExecutionStrategy mutationStrategy,
ExecutionStrategy subscriptionStrategy,
Instrumentation instrumentation,
ValueUnboxer valueUnboxer,
ResponseMapFactory responseMapFactory,
boolean doNotAutomaticallyDispatchDataLoader) {
this.queryStrategy = queryStrategy != null ? queryStrategy : new AsyncExecutionStrategy();
this.mutationStrategy = mutationStrategy != null ? mutationStrategy : new AsyncSerialExecutionStrategy();
this.subscriptionStrategy = subscriptionStrategy != null ? subscriptionStrategy : new AsyncExecutionStrategy();
this.instrumentation = instrumentation;
this.valueUnboxer = valueUnboxer;
this.responseMapFactory = responseMapFactory;
this.doNotAutomaticallyDispatchDataLoader = doNotAutomaticallyDispatchDataLoader;
}

Expand Down Expand Up @@ -110,6 +113,7 @@ public CompletableFuture<ExecutionResult> execute(Document document, GraphQLSche
.dataLoaderRegistry(executionInput.getDataLoaderRegistry())
.locale(executionInput.getLocale())
.valueUnboxer(valueUnboxer)
.responseMapFactory(responseMapFactory)
.executionInput(executionInput)
.propagapropagateErrorsOnNonNullContractFailureeErrors(propagateErrorsOnNonNullContractFailure)
.engineRunningState(engineRunningState)
Expand Down
8 changes: 7 additions & 1 deletion src/main/java/graphql/execution/ExecutionContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public class ExecutionContext {
private final Locale locale;
private final IncrementalCallState incrementalCallState = new IncrementalCallState();
private final ValueUnboxer valueUnboxer;
private final ResponseMapFactory responseMapFactory;

private final ExecutionInput executionInput;
private final Supplier<ExecutableNormalizedOperation> queryTree;
private final boolean propagateErrorsOnNonNullContractFailure;
Expand Down Expand Up @@ -92,6 +94,7 @@ public class ExecutionContext {
this.dataLoaderRegistry = builder.dataLoaderRegistry;
this.locale = builder.locale;
this.valueUnboxer = builder.valueUnboxer;
this.responseMapFactory = builder.responseMapFactory;
this.errors.set(builder.errors);
this.localContext = builder.localContext;
this.executionInput = builder.executionInput;
Expand All @@ -101,7 +104,6 @@ public class ExecutionContext {
this.engineRunningState = builder.engineRunningState;
}


public ExecutionId getExecutionId() {
return executionId;
}
Expand Down Expand Up @@ -295,6 +297,10 @@ public void addErrors(List<GraphQLError> errors) {
});
}

public ResponseMapFactory getResponseMapFactory() {
return responseMapFactory;
}

/**
* @return the total list of errors for this execution context
*/
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/graphql/execution/ExecutionContextBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public class ExecutionContextBuilder {
DataLoaderDispatchStrategy dataLoaderDispatcherStrategy = DataLoaderDispatchStrategy.NO_OP;
boolean propagateErrorsOnNonNullContractFailure = true;
EngineRunningState engineRunningState;
ResponseMapFactory responseMapFactory = ResponseMapFactory.DEFAULT;

/**
* @return a new builder of {@link graphql.execution.ExecutionContext}s
Expand Down Expand Up @@ -100,6 +101,7 @@ public ExecutionContextBuilder() {
dataLoaderDispatcherStrategy = other.getDataLoaderDispatcherStrategy();
propagateErrorsOnNonNullContractFailure = other.propagateErrorsOnNonNullContractFailure();
engineRunningState = other.getEngineRunningState();
responseMapFactory = other.getResponseMapFactory();
}

public ExecutionContextBuilder instrumentation(Instrumentation instrumentation) {
Expand Down Expand Up @@ -224,6 +226,11 @@ public ExecutionContextBuilder dataLoaderDispatcherStrategy(DataLoaderDispatchSt
return this;
}

public ExecutionContextBuilder responseMapFactory(ResponseMapFactory responseMapFactory) {
this.responseMapFactory = responseMapFactory;
return this;
}

public ExecutionContextBuilder resetErrors() {
this.errors = emptyList();
return this;
Expand Down
16 changes: 2 additions & 14 deletions src/main/java/graphql/execution/ExecutionStrategy.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package graphql.execution;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.Maps;
import graphql.DuckTyped;
import graphql.EngineRunningState;
import graphql.ExecutionResult;
Expand Down Expand Up @@ -260,7 +259,7 @@ protected Object executeObject(ExecutionContext executionContext, ExecutionStrat
overallResult.whenComplete(resolveObjectCtx::onCompleted);
return overallResult;
} else {
Map<String, Object> fieldValueMap = buildFieldValueMap(fieldsExecutedOnInitialResult, (List<Object>) completedValuesObject);
Map<String, Object> fieldValueMap = executionContext.getResponseMapFactory().createInsertionOrdered(fieldsExecutedOnInitialResult, (List<Object>) completedValuesObject);
resolveObjectCtx.onCompleted(fieldValueMap, null);
return fieldValueMap;
}
Expand All @@ -281,22 +280,11 @@ private BiConsumer<List<Object>, Throwable> buildFieldValueMap(List<String> fiel
handleValueException(overallResult, exception, executionContext);
return;
}
Map<String, Object> resolvedValuesByField = buildFieldValueMap(fieldNames, results);
Map<String, Object> resolvedValuesByField = executionContext.getResponseMapFactory().createInsertionOrdered(fieldNames, results);
overallResult.complete(resolvedValuesByField);
};
}

@NonNull
private static Map<String, Object> buildFieldValueMap(List<String> fieldNames, List<Object> results) {
Map<String, Object> resolvedValuesByField = Maps.newLinkedHashMapWithExpectedSize(fieldNames.size());
int ix = 0;
for (Object fieldValue : results) {
String fieldName = fieldNames.get(ix++);
resolvedValuesByField.put(fieldName, fieldValue);
}
return resolvedValuesByField;
}

DeferredExecutionSupport createDeferredExecutionSupport(ExecutionContext executionContext, ExecutionStrategyParameters parameters) {
MergedSelectionSet fields = parameters.getFields();

Expand Down
32 changes: 32 additions & 0 deletions src/main/java/graphql/execution/ResponseMapFactory.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package graphql.execution;

import graphql.ExperimentalApi;
import graphql.PublicSpi;

import java.util.List;
import java.util.Map;

/**
* Allows to customize the concrete class {@link Map} implementation. For example, it could be possible to use
* memory-efficient implementations, like eclipse-collections.
*/
@ExperimentalApi
@PublicSpi
public interface ResponseMapFactory {

/**
* The default implementation uses JDK's {@link java.util.LinkedHashMap}.
*/
ResponseMapFactory DEFAULT = new DefaultResponseMapFactory();

/**
* The general contract is that the resulting map keeps the insertion orders of keys. Values are nullable but keys are not.
* Implementations are free to create or to reuse map instances.
*
* @param keys the keys like k1, k2, ..., kn
* @param values the values like v1, v2, ..., vn
* @return a new or reused map instance with (k1,v1), (k2, v2), ... (kn, vn)
*/
Map<String, Object> createInsertionOrdered(List<String> keys, List<Object> values);

}
69 changes: 69 additions & 0 deletions src/test/groovy/graphql/CustomMapImplementationTest.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package graphql

import graphql.execution.ResponseMapFactory
import graphql.schema.idl.RuntimeWiring
import groovy.transform.Immutable
import spock.lang.Specification

class CustomMapImplementationTest extends Specification {

@Immutable
static class Person {
String name
@SuppressWarnings('unused') // used by graphql-java
int age
}

class CustomResponseMapFactory implements ResponseMapFactory {

@Override
Map<String, Object> createInsertionOrdered(List<String> keys, List<Object> values) {
return Collections.unmodifiableMap(DEFAULT.createInsertionOrdered(keys, values))
}
}

def graphql = TestUtil.graphQL("""
type Query {
people: [Person!]!
}

type Person {
name: String!
age: Int!
}

""",
RuntimeWiring.newRuntimeWiring()
.type("Query", {
it.dataFetcher("people", { List.of(new Person("Mario", 18), new Person("Luigi", 21))})
})
.build())
.responseMapFactory(new CustomResponseMapFactory())
.build()

def "customMapImplementation"() {
when:
def input = ExecutionInput.newExecutionInput()
.query('''
query {
people {
name
age
}
}
''')
.build()

def executionResult = graphql.execute(input)

then:
executionResult.errors.isEmpty()
executionResult.data == [ people: [
[name: "Mario", age: 18],
[name: "Luigi", age: 21],
]]
executionResult.data.getClass().getSimpleName() == 'UnmodifiableMap'
executionResult.data['people'].each { it -> it.getClass().getSimpleName() == 'UnmodifiableMap' }
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package graphql.execution

import spock.lang.Specification

class DefaultResponseMapFactoryTest extends Specification {

def "no keys"() {
given:
var sut = new DefaultResponseMapFactory()

when:
var result = sut.createInsertionOrdered(List.of(), List.of())

then:
result.isEmpty()
result instanceof LinkedHashMap
}

def "1 key"() {
given:
var sut = new DefaultResponseMapFactory()

when:
var result = sut.createInsertionOrdered(List.of("name"), List.of("Mario"))

then:
result == ["name": "Mario"]
result instanceof LinkedHashMap
}

def "2 keys"() {
given:
var sut = new DefaultResponseMapFactory()

when:
var result = sut.createInsertionOrdered(List.of("name", "age"), List.of("Mario", 18))

then:
result == ["name": "Mario", "age": 18]
result instanceof LinkedHashMap
}
}
Loading
Loading