From 01ace235188e674cc692c913c75271c32d7a24aa Mon Sep 17 00:00:00 2001 From: Antoine Boyer Date: Thu, 8 Jan 2026 16:34:57 -0800 Subject: [PATCH 1/4] Add FieldFetchingInstrumentationContext.onExceptionHandled for hooking after exception handling --- .../graphql/execution/ExecutionStrategy.java | 21 ++- .../FieldFetchingInstrumentationContext.java | 10 ++ .../InstrumentationTest.groovy | 166 ++++++++++++++++++ 3 files changed, 189 insertions(+), 8 deletions(-) diff --git a/src/main/java/graphql/execution/ExecutionStrategy.java b/src/main/java/graphql/execution/ExecutionStrategy.java index 06d3b644b1..78ad4280b8 100644 --- a/src/main/java/graphql/execution/ExecutionStrategy.java +++ b/src/main/java/graphql/execution/ExecutionStrategy.java @@ -489,12 +489,17 @@ private Object fetchField(GraphQLFieldDefinition fieldDef, ExecutionContext exec CompletableFuture> handleCF = engineRunningState.handle(fetchedValue, (result, exception) -> { // because we added an artificial CF, we need to unwrap the exception - fetchCtx.onCompleted(result, exception); - exception = engineRunningState.possibleCancellation(exception); - - if (exception != null) { - return handleFetchingException(dataFetchingEnvironment.get(), parameters, exception); + Throwable possibleWrappedException = engineRunningState.possibleCancellation(exception); + + if (possibleWrappedException != null) { + CompletableFuture> handledExceptionResult = handleFetchingException(dataFetchingEnvironment.get(), parameters, possibleWrappedException); + return handledExceptionResult.thenApply( handledResult -> { + fetchCtx.onExceptionHandled(handledResult); + fetchCtx.onCompleted(result, exception); + return handledResult; + }); } else { + fetchCtx.onCompleted(result, exception); // we can simply return the fetched value CF and avoid a allocation return fetchedValue; } @@ -578,7 +583,7 @@ private void addExtensionsIfPresent(ExecutionContext executionContext, DataFetch } } - protected CompletableFuture handleFetchingException( + protected CompletableFuture> handleFetchingException( DataFetchingEnvironment environment, ExecutionStrategyParameters parameters, Throwable e @@ -599,10 +604,10 @@ protected CompletableFuture handleFetchingException( } } - private CompletableFuture asyncHandleException(DataFetcherExceptionHandler handler, DataFetcherExceptionHandlerParameters handlerParameters) { + private CompletableFuture> asyncHandleException(DataFetcherExceptionHandler handler, DataFetcherExceptionHandlerParameters handlerParameters) { //noinspection unchecked return handler.handleException(handlerParameters).thenApply( - handlerResult -> (T) DataFetcherResult.newResult().errors(handlerResult.getErrors()).build() + handlerResult -> (DataFetcherResult) DataFetcherResult.newResult().errors(handlerResult.getErrors()).build() ); } diff --git a/src/main/java/graphql/execution/instrumentation/FieldFetchingInstrumentationContext.java b/src/main/java/graphql/execution/instrumentation/FieldFetchingInstrumentationContext.java index 38984c6f92..f6ff09beca 100644 --- a/src/main/java/graphql/execution/instrumentation/FieldFetchingInstrumentationContext.java +++ b/src/main/java/graphql/execution/instrumentation/FieldFetchingInstrumentationContext.java @@ -2,6 +2,7 @@ import graphql.Internal; import graphql.PublicSpi; +import graphql.execution.DataFetcherResult; import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -26,6 +27,15 @@ public interface FieldFetchingInstrumentationContext extends InstrumentationCont default void onFetchedValue(Object fetchedValue) { } + /** + * This is called back after any {@link graphql.execution.DataFetcherExceptionHandler}) has run on any exception raised + * during a {@link graphql.schema.DataFetcher} invocation. This allows to see the final {@link DataFetcherResult} + * that will be used when performing the complete step. + * @param dataFetcherResult the final {@link DataFetcherResult} after the exception handler has run + */ + default void onExceptionHandled(DataFetcherResult dataFetcherResult) { + } + @Internal FieldFetchingInstrumentationContext NOOP = new FieldFetchingInstrumentationContext() { @Override diff --git a/src/test/groovy/graphql/execution/instrumentation/InstrumentationTest.groovy b/src/test/groovy/graphql/execution/instrumentation/InstrumentationTest.groovy index 43767d9348..ca52f8cf84 100644 --- a/src/test/groovy/graphql/execution/instrumentation/InstrumentationTest.groovy +++ b/src/test/groovy/graphql/execution/instrumentation/InstrumentationTest.groovy @@ -1,11 +1,16 @@ package graphql.execution.instrumentation +import graphql.ErrorType import graphql.ExecutionInput import graphql.ExecutionResult import graphql.GraphQL +import graphql.GraphqlErrorBuilder +import graphql.GraphqlErrorBuilderTest import graphql.StarWarsSchema import graphql.TestUtil import graphql.execution.AsyncExecutionStrategy +import graphql.execution.DataFetcherExceptionHandlerResult +import graphql.execution.DataFetcherResult import graphql.execution.instrumentation.parameters.InstrumentationCreateStateParameters import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters import graphql.execution.instrumentation.parameters.InstrumentationExecutionStrategyParameters @@ -23,6 +28,8 @@ import spock.lang.Specification import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong class InstrumentationTest extends Specification { @@ -152,6 +159,165 @@ class InstrumentationTest extends Specification { instrumentation.throwableList[0].getMessage() == "DF BANG!" } + def "field fetch will instrument exceptions correctly - includes exception handling with onExceptionHandled"() { + + given: + + def query = """ + { + hero { + id + } + } + """ + + def instrumentation = new LegacyTestingInstrumentation() { + def onHandledCalled = false + def onCompletedCalled = false + def onDispatchedCalled = false + + @Override + DataFetcher instrumentDataFetcher(DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, InstrumentationState state) { + return new DataFetcher() { + @Override + Object get(DataFetchingEnvironment environment) { + throw new RuntimeException("DF BANG!") + } + } + } + + @Override + FieldFetchingInstrumentationContext beginFieldFetching(InstrumentationFieldFetchParameters parameters, InstrumentationState state) { + return new FieldFetchingInstrumentationContext() { + @Override + void onDispatched() { + onDispatchedCalled = true + } + + @Override + void onCompleted(Object result, Throwable t) { + onCompletedCalled = true + } + + @Override + void onExceptionHandled(DataFetcherResult dataFetcherResult) { + onHandledCalled = true + } + } + } + } + + def graphQL = GraphQL + .newGraphQL(StarWarsSchema.starWarsSchema) + .defaultDataFetcherExceptionHandler { it -> + // catch all exceptions and transform to graphql error with a prefixed message + return CompletableFuture.completedFuture( + DataFetcherExceptionHandlerResult.newResult(GraphqlErrorBuilder.newError() + .errorType(ErrorType.DataFetchingException) + .message("Handled " + it.exception.message) + .path(it.path) + .build()) + .build()) + } + .instrumentation(instrumentation) + .build() + + when: + def resp = graphQL.execute(query) + + then: "exception handler turned the exception into a graphql error and message prefixed with Handled" + resp.errors.size() == 1 + resp.errors[0].message == "Handled DF BANG!" + + and: "all instrumentation methods were called" + instrumentation.onDispatchedCalled == true + instrumentation.onCompletedCalled == true + instrumentation.onHandledCalled == true + } + + + def "field fetch verify order and call of all methods"() { + + given: + + def query = """ + { + hero { + id + } + } + """ + + def metric = [] + def instrumentation = new SimplePerformantInstrumentation() { + def timeElapsed = new AtomicInteger() + + @Override + DataFetcher instrumentDataFetcher(DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, InstrumentationState state) { + return new DataFetcher() { + @Override + Object get(DataFetchingEnvironment environment) { + // simulate latency + timeElapsed.addAndGet(50) + throw new RuntimeException("DF BANG!") + } + } + } + + @Override + FieldFetchingInstrumentationContext beginFieldFetching(InstrumentationFieldFetchParameters parameters, InstrumentationState state) { + return new FieldFetchingInstrumentationContext() { + def start = 0 + def duration = 0 + def hasError = false + + + @Override + void onDispatched() { + start = 1 + } + + @Override + void onCompleted(Object result, Throwable t) { + duration = timeElapsed.get() - start + metric = [duration, hasError] + } + + @Override + void onExceptionHandled(DataFetcherResult dataFetcherResult) { + hasError = dataFetcherResult.errors != null && !dataFetcherResult.errors.isEmpty() + && dataFetcherResult.errors.any { it.message.contains("Handled") } + } + } + } + } + + def graphQL = GraphQL + .newGraphQL(StarWarsSchema.starWarsSchema) + .defaultDataFetcherExceptionHandler { it -> + // catch all exceptions and transform to graphql error with a prefixed message + return CompletableFuture.completedFuture( + DataFetcherExceptionHandlerResult.newResult(GraphqlErrorBuilder.newError() + .errorType(ErrorType.DataFetchingException) + .message("Handled " + it.exception.message) + .path(it.path) + .build()) + .build()) + } + .instrumentation(instrumentation) + .build() + + when: + def resp = graphQL.execute(query) + + then: "exception handler turned the exception into a graphql error and prefixed its message with 'Handled'" + resp.errors.size() == 1 + resp.errors[0].message == "Handled DF BANG!" + + and: "metric was captured i.e all instrumentation methods were called in the right order" + metric == [49, true] + } + /** * This uses a stop and go pattern and multiple threads. Each time * the execution strategy is invoked, the data fetchers are held From 835e9db34c6beeabe64c011cefb41ca9dea36b75 Mon Sep 17 00:00:00 2001 From: Antoine Boyer Date: Tue, 13 Jan 2026 15:57:47 -0800 Subject: [PATCH 2/4] Add FieldFetchingInstrumentationContext.onExceptionHandled call to ChainedInstrumentation Missed it from PR #4206 --- .../ChainedInstrumentation.java | 6 ++ .../ChainedInstrumentationStateTest.groovy | 74 +++++++++++++++++++ ...FieldFetchingInstrumentationContext.groovy | 7 ++ .../TestingInstrumentContext.groovy | 2 - 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/main/java/graphql/execution/instrumentation/ChainedInstrumentation.java b/src/main/java/graphql/execution/instrumentation/ChainedInstrumentation.java index 2779a10a01..4ae65742bf 100644 --- a/src/main/java/graphql/execution/instrumentation/ChainedInstrumentation.java +++ b/src/main/java/graphql/execution/instrumentation/ChainedInstrumentation.java @@ -6,6 +6,7 @@ import graphql.ExperimentalApi; import graphql.PublicApi; import graphql.execution.Async; +import graphql.execution.DataFetcherResult; import graphql.execution.ExecutionContext; import graphql.execution.FieldValueInfo; import graphql.execution.instrumentation.parameters.InstrumentationCreateStateParameters; @@ -380,6 +381,11 @@ public void onFetchedValue(Object fetchedValue) { contexts.forEach(context -> context.onFetchedValue(fetchedValue)); } + @Override + public void onExceptionHandled(DataFetcherResult dataFetcherResult) { + contexts.forEach(context -> context.onExceptionHandled(dataFetcherResult)); + } + @Override public void onCompleted(Object result, Throwable t) { contexts.forEach(context -> context.onCompleted(result, t)); diff --git a/src/test/groovy/graphql/execution/instrumentation/ChainedInstrumentationStateTest.groovy b/src/test/groovy/graphql/execution/instrumentation/ChainedInstrumentationStateTest.groovy index 88d7c86538..a0c67d6848 100644 --- a/src/test/groovy/graphql/execution/instrumentation/ChainedInstrumentationStateTest.groovy +++ b/src/test/groovy/graphql/execution/instrumentation/ChainedInstrumentationStateTest.groovy @@ -8,6 +8,8 @@ import graphql.execution.AsyncExecutionStrategy import graphql.execution.instrumentation.parameters.InstrumentationCreateStateParameters import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters import graphql.execution.instrumentation.parameters.InstrumentationValidationParameters +import graphql.schema.DataFetcher +import graphql.schema.DataFetchingEnvironment import graphql.validation.ValidationError import spock.lang.Specification @@ -227,6 +229,78 @@ class ChainedInstrumentationStateTest extends Specification { assertCalls(c) } + def "basic chaining and state management when exception raised in data fetching"() { + + def a = new NamedInstrumentation("A") + def b = new NamedInstrumentation("B") + def c = new NamedInstrumentation("C") + def nullState = new SimplePerformantInstrumentation() + + def chainedInstrumentation = new ChainedInstrumentation([ + a, + b, + nullState, + c, + ]) + + def query = """ + query HeroNameAndFriendsQuery { + hero { + id + } + } + """ + + def expected = "onExceptionHandled:fetch-id" + + + when: + def strategy = new AsyncExecutionStrategy() + def schema = StarWarsSchema.starWarsSchema + def graphQL = GraphQL + .newGraphQL(schema.transform { schemaBuilder -> + // throw exception when fetching the hero id + def exceptionDataFetcher = new DataFetcher() { + @Override + Object get(DataFetchingEnvironment environment) { + throw new RuntimeException("Data fetcher exception") + } + } + schemaBuilder.codeRegistry(schema.codeRegistry.transform { + it.dataFetcher( + schema.getObjectType("Human"), + schema.getObjectType("Human").getFieldDefinition("id"), + exceptionDataFetcher + ) + it.dataFetcher( + schema.getObjectType("Droid"), + schema.getObjectType("Droid").getFieldDefinition("id"), + exceptionDataFetcher + ) + return it + } + ) + }) + .queryExecutionStrategy(strategy) + .instrumentation(chainedInstrumentation) + .build() + + graphQL.execute(query) + + then: + + chainedInstrumentation.getInstrumentations().size() == 4 + + a.executionList.any { it == expected } + b.executionList.any { it == expected } + c.executionList.any { it == expected } + + assertCalls(a) + assertCalls(b) + assertCalls(c) + + } + def "empty chain"() { def chainedInstrumentation = new ChainedInstrumentation(Arrays.asList()) diff --git a/src/test/groovy/graphql/execution/instrumentation/TestingFieldFetchingInstrumentationContext.groovy b/src/test/groovy/graphql/execution/instrumentation/TestingFieldFetchingInstrumentationContext.groovy index 50fcaccd2e..c9e19ba631 100644 --- a/src/test/groovy/graphql/execution/instrumentation/TestingFieldFetchingInstrumentationContext.groovy +++ b/src/test/groovy/graphql/execution/instrumentation/TestingFieldFetchingInstrumentationContext.groovy @@ -1,9 +1,16 @@ package graphql.execution.instrumentation +import graphql.execution.DataFetcherResult + class TestingFieldFetchingInstrumentationContext extends TestingInstrumentContext implements FieldFetchingInstrumentationContext { TestingFieldFetchingInstrumentationContext(Object op, Object executionList, Object throwableList, Boolean useOnDispatch) { super(op, executionList, throwableList, useOnDispatch) } + + @Override + void onExceptionHandled(DataFetcherResult dataFetcherResult) { + executionList << "onExceptionHandled:$op" + } } diff --git a/src/test/groovy/graphql/execution/instrumentation/TestingInstrumentContext.groovy b/src/test/groovy/graphql/execution/instrumentation/TestingInstrumentContext.groovy index 402fd2aee0..3e627b3684 100644 --- a/src/test/groovy/graphql/execution/instrumentation/TestingInstrumentContext.groovy +++ b/src/test/groovy/graphql/execution/instrumentation/TestingInstrumentContext.groovy @@ -1,7 +1,5 @@ package graphql.execution.instrumentation -import java.util.concurrent.CompletableFuture - class TestingInstrumentContext implements InstrumentationContext { def op def start = System.currentTimeMillis() From 6d6191210fffb8e383f78cab3769ed1e1f51e0f0 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Mon, 19 Jan 2026 12:56:12 +1000 Subject: [PATCH 3/4] Add documentation and test for additionalTypes in GraphQLSchema - Add comprehensive JavaDoc to getAdditionalTypes() explaining when additional types are needed (interface implementations, SDL schemas, programmatic schemas with type references) - Add JavaDoc to builder methods additionalTypes(), additionalType(), and clearAdditionalTypes() - Document that there are no restrictions on what types can be added - Add test demonstrating that additionalTypes accepts any type, including types already reachable from root operations - Add .vscode to .gitignore --- .gitignore | 3 +- .../java/graphql/schema/GraphQLSchema.java | 100 ++++++++++++++++ .../graphql/schema/GraphQLSchemaTest.groovy | 112 ++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index dad8d9885c..24e536c805 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ docs/_build/ \.settings/ /.nb-gradle/ gen -.DS_Store \ No newline at end of file +.DS_Store +.vscode \ No newline at end of file diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index ec272c3131..c445f88b72 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -219,6 +219,50 @@ public GraphQLObjectType getIntrospectionSchemaType() { return introspectionSchemaType; } + /** + * Returns the set of "additional types" that were provided when building the schema. + *

+ * Additional types are types that are not directly reachable via the root operation types + * (Query, Mutation, Subscription) but still need to be part of the schema. The most common + * use case is for interface implementations that are not directly referenced in field return types. + *

+ * When additional types are typically needed: + *

    + *
  • Interface implementations: When an interface is used as a field's return type, + * implementing types that are not directly referenced elsewhere need to be added so they + * can be resolved at runtime and appear in introspection.
  • + *
  • SDL-defined schemas: When building from SDL, the {@link graphql.schema.idl.SchemaGenerator} + * automatically detects types not connected to root operations and adds them as additional types.
  • + *
  • Programmatic schemas with type references: When using {@link GraphQLTypeReference} + * to break circular dependencies, the actual type implementations may need to be provided + * as additional types.
  • + *
+ *

+ * Example - Interface implementation not directly referenced: + *

{@code
+     * // Given this schema:
+     * // type Query { node: Node }
+     * // interface Node { id: ID! }
+     * // type User implements Node { id: ID!, name: String }
+     * //
+     * // User is not directly referenced from Query, so it needs to be added:
+     * GraphQLSchema.newSchema()
+     *     .query(queryType)
+     *     .additionalType(GraphQLObjectType.newObject().name("User")...)
+     *     .build();
+     * }
+ *

+ * Note: There are no restrictions on what types can be added via this mechanism. + * Types that are already reachable from the root operations can also be added without causing + * errors - they will simply be present in both the type map (via traversal) and this set. + * After schema construction, use {@link #getTypeMap()} or {@link #getAllTypesAsList()} to get + * all types in the schema regardless of how they were discovered. + * + * @return an immutable set of types that were explicitly added as additional types + * + * @see Builder#additionalType(GraphQLType) + * @see Builder#additionalTypes(Set) + */ public Set getAdditionalTypes() { return additionalTypes; } @@ -722,16 +766,72 @@ public Builder codeRegistry(GraphQLCodeRegistry codeRegistry) { return this; } + /** + * Adds multiple types to the set of additional types. + *

+ * Additional types are types that may not be directly reachable by traversing the schema + * from the root operation types (Query, Mutation, Subscription), but still need to be + * included in the schema. The most common use case is for object types that implement + * an interface but are not directly referenced as field return types. + *

+ * Example - Adding interface implementations: + *

{@code
+         * // If Node interface is used but User/Post types aren't directly referenced:
+         * builder.additionalTypes(Set.of(
+         *     GraphQLObjectType.newObject().name("User").withInterface(nodeInterface)...,
+         *     GraphQLObjectType.newObject().name("Post").withInterface(nodeInterface)...
+         * ));
+         * }
+ *

+ * Note: There are no restrictions on what types can be added. Types already + * reachable from root operations can be added without causing errors - they will + * simply exist in both the traversed type map and this set. + * + * @param additionalTypes the types to add + * + * @return this builder + * + * @see GraphQLSchema#getAdditionalTypes() + */ public Builder additionalTypes(Set additionalTypes) { this.additionalTypes.addAll(additionalTypes); return this; } + /** + * Adds a single type to the set of additional types. + *

+ * Additional types are types that may not be directly reachable by traversing the schema + * from the root operation types (Query, Mutation, Subscription), but still need to be + * included in the schema. The most common use case is for object types that implement + * an interface but are not directly referenced as field return types. + *

+ * Note: There are no restrictions on what types can be added. Types already + * reachable from root operations can be added without causing errors. + * + * @param additionalType the type to add + * + * @return this builder + * + * @see GraphQLSchema#getAdditionalTypes() + * @see #additionalTypes(Set) + */ public Builder additionalType(GraphQLType additionalType) { this.additionalTypes.add(additionalType); return this; } + /** + * Clears all additional types that have been added to this builder. + *

+ * This is useful when transforming an existing schema and you want to + * rebuild the additional types set from scratch. + * + * @return this builder + * + * @see #additionalType(GraphQLType) + * @see #additionalTypes(Set) + */ public Builder clearAdditionalTypes() { this.additionalTypes.clear(); return this; diff --git a/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy b/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy index 73a04346f2..a3e5d624e2 100644 --- a/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy +++ b/src/test/groovy/graphql/schema/GraphQLSchemaTest.groovy @@ -563,4 +563,116 @@ class GraphQLSchemaTest extends Specification { } + def "additionalTypes can contain any type when building programmatically - not restricted to detached types"() { + given: "types that will be directly reachable from Query" + def simpleType = newObject() + .name("SimpleType") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def simpleInputType = newInputObject() + .name("SimpleInput") + .field(newInputObjectField() + .name("value") + .type(GraphQLString)) + .build() + + def simpleInterface = GraphQLInterfaceType.newInterface() + .name("SimpleInterface") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def simpleUnion = GraphQLUnionType.newUnionType() + .name("SimpleUnion") + .possibleType(simpleType) + .build() + + def simpleEnum = GraphQLEnumType.newEnum() + .name("SimpleEnum") + .value("VALUE_A") + .value("VALUE_B") + .build() + + def simpleScalar = GraphQLScalarType.newScalar() + .name("SimpleScalar") + .coercing(new Coercing() { + @Override + Object serialize(Object dataFetcherResult) { return dataFetcherResult } + + @Override + Object parseValue(Object input) { return input } + + @Override + Object parseLiteral(Object input) { return input } + }) + .build() + + and: "a query type that references all these types directly" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("simpleField") + .type(simpleType)) + .field(newFieldDefinition() + .name("interfaceField") + .type(simpleInterface)) + .field(newFieldDefinition() + .name("unionField") + .type(simpleUnion)) + .field(newFieldDefinition() + .name("enumField") + .type(simpleEnum)) + .field(newFieldDefinition() + .name("scalarField") + .type(simpleScalar)) + .field(newFieldDefinition() + .name("inputField") + .type(GraphQLString) + .argument(newArgument() + .name("input") + .type(simpleInputType))) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver(simpleInterface, { env -> simpleType }) + .typeResolver(simpleUnion, { env -> simpleType }) + .build() + + when: "we add ALL types (including already reachable ones) as additionalTypes" + def schema = GraphQLSchema.newSchema() + .query(queryType) + .codeRegistry(codeRegistry) + .additionalType(simpleType) // already reachable via Query.simpleField + .additionalType(simpleInputType) // already reachable via Query.inputField argument + .additionalType(simpleInterface) // already reachable via Query.interfaceField + .additionalType(simpleUnion) // already reachable via Query.unionField + .additionalType(simpleEnum) // already reachable via Query.enumField + .additionalType(simpleScalar) // already reachable via Query.scalarField + .build() + + then: "schema builds successfully - no restriction on what can be in additionalTypes" + schema != null + + and: "all types are in the type map (as expected)" + schema.getType("SimpleType") == simpleType + schema.getType("SimpleInput") == simpleInputType + schema.getType("SimpleInterface") == simpleInterface + schema.getType("SimpleUnion") == simpleUnion + schema.getType("SimpleEnum") == simpleEnum + schema.getType("SimpleScalar") == simpleScalar + + and: "additionalTypes contains all types we added - even though they were already reachable" + schema.getAdditionalTypes().size() == 6 + schema.getAdditionalTypes().contains(simpleType) + schema.getAdditionalTypes().contains(simpleInputType) + schema.getAdditionalTypes().contains(simpleInterface) + schema.getAdditionalTypes().contains(simpleUnion) + schema.getAdditionalTypes().contains(simpleEnum) + schema.getAdditionalTypes().contains(simpleScalar) + } + } From c4fc1d0d9cd213557af300b957d5fd2f75c2d7fd Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Mon, 19 Jan 2026 13:01:48 +1000 Subject: [PATCH 4/4] improve javadoc --- .../java/graphql/schema/GraphQLSchema.java | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index c445f88b72..92ff53835c 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -222,17 +222,32 @@ public GraphQLObjectType getIntrospectionSchemaType() { /** * Returns the set of "additional types" that were provided when building the schema. *

- * Additional types are types that are not directly reachable via the root operation types - * (Query, Mutation, Subscription) but still need to be part of the schema. The most common - * use case is for interface implementations that are not directly referenced in field return types. + * During schema construction, types are discovered by traversing the schema from multiple roots: + *

    + *
  • Root operation types (Query, Mutation, Subscription)
  • + *
  • Directive argument types
  • + *
  • Introspection types
  • + *
  • Types explicitly added via {@link Builder#additionalType(GraphQLType)}
  • + *
+ *

+ * Additional types are types that are not reachable via any of the automatic traversal paths + * but still need to be part of the schema. The most common use case is for interface + * implementations that are not directly referenced elsewhere. + *

+ * Types that do NOT need to be added as additional types: + *

    + *
  • Types reachable from Query, Mutation, or Subscription fields
  • + *
  • Types used as directive arguments (these are discovered via directive traversal)
  • + *
*

- * When additional types are typically needed: + * When additional types ARE typically needed: *

    *
  • Interface implementations: When an interface is used as a field's return type, - * implementing types that are not directly referenced elsewhere need to be added so they - * can be resolved at runtime and appear in introspection.
  • + * implementing object types are not automatically discovered because interfaces do not + * reference their implementors. These need to be added so they can be resolved at runtime + * and appear in introspection. *
  • SDL-defined schemas: When building from SDL, the {@link graphql.schema.idl.SchemaGenerator} - * automatically detects types not connected to root operations and adds them as additional types.
  • + * automatically detects types not connected to any root and adds them as additional types. *
  • Programmatic schemas with type references: When using {@link GraphQLTypeReference} * to break circular dependencies, the actual type implementations may need to be provided * as additional types.
  • @@ -253,7 +268,7 @@ public GraphQLObjectType getIntrospectionSchemaType() { * } *

    * Note: There are no restrictions on what types can be added via this mechanism. - * Types that are already reachable from the root operations can also be added without causing + * Types that are already reachable from other roots can also be added without causing * errors - they will simply be present in both the type map (via traversal) and this set. * After schema construction, use {@link #getTypeMap()} or {@link #getAllTypesAsList()} to get * all types in the schema regardless of how they were discovered.