From e105a4925528610b43cd6e2611c3ef35afbf5df6 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Thu, 11 Dec 2025 08:14:22 +0000 Subject: [PATCH 01/25] Optimize assertValidName by replacing regex with character-by-character check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Pattern.matcher().matches() with direct character validation in Assert.assertValidName(). This method is called in the constructor of every GraphQL type (objects, interfaces, unions, enums, inputs, scalars), field, argument, and directive during schema construction. For large schemas (18k+ types), this eliminates tens of thousands of regex compilations and matches, providing significant performance improvement: - FastBuilder: 25% faster (364ms → 272ms on 18,837-type schema) - Standard Builder: 4% faster (1967ms → 1883ms on same schema) The character-by-character check maintains identical validation semantics while avoiding regex Pattern compilation and Matcher object allocation overhead on every name validation. --- src/main/java/graphql/Assert.java | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/java/graphql/Assert.java b/src/main/java/graphql/Assert.java index 8a2925278..54ca91784 100644 --- a/src/main/java/graphql/Assert.java +++ b/src/main/java/graphql/Assert.java @@ -224,8 +224,6 @@ public static void assertFalse(boolean condition, String msgFmt, Object arg1, Ob private static final String invalidNameErrorMessage = "Name must be non-null, non-empty and match [_A-Za-z][_0-9A-Za-z]* - was '%s'"; - private static final Pattern validNamePattern = Pattern.compile("[_A-Za-z][_0-9A-Za-z]*"); - /** * Validates that the Lexical token name matches the current spec. * currently non null, non empty, @@ -235,12 +233,38 @@ public static void assertFalse(boolean condition, String msgFmt, Object arg1, Ob * @return the name if valid, or AssertException if invalid. */ public static String assertValidName(String name) { - if (name != null && !name.isEmpty() && validNamePattern.matcher(name).matches()) { + if (name != null && !name.isEmpty() && isValidName(name)) { return name; } return throwAssert(invalidNameErrorMessage, name); } + /** + * Fast character-by-character validation without regex. + * Checks if name matches [_A-Za-z][_0-9A-Za-z]* + */ + private static boolean isValidName(String name) { + if (name.isEmpty()) { + return false; + } + + // First character must be [_A-Za-z] + char first = name.charAt(0); + if (!(first == '_' || (first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z'))) { + return false; + } + + // Remaining characters must be [_0-9A-Za-z] + for (int i = 1; i < name.length(); i++) { + char c = name.charAt(i); + if (!(c == '_' || (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) { + return false; + } + } + + return true; + } + private static T throwAssert(String format, Object... args) { throw new AssertException(format(format, args)); } From cadb12a23d23489b4aab9cf2d12e798758c64f0c Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sat, 6 Dec 2025 21:29:06 +0000 Subject: [PATCH 02/25] Phase 1: Add FastBuilder scaffolding with scalar type support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GraphQLSchema.FastBuilder, a high-performance schema builder that avoids full-schema traversals. This initial implementation includes: - FastBuilder class with constructor accepting code registry builder and root types (query, mutation, subscription) - New private GraphQLSchema constructor for FastBuilder - ShallowTypeRefCollector stub class for future type reference handling - Support for adding scalar types via additionalType() - Automatic addition of built-in directives - Optional validation support (disabled by default) - Comprehensive test suite in FastBuilderTest.groovy 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/graphql/schema/GraphQLSchema.java | 379 ++++++++++++++++++ .../schema/impl/ShallowTypeRefCollector.java | 58 +++ .../graphql/schema/FastBuilderTest.groovy | 273 +++++++++++++ 3 files changed, 710 insertions(+) create mode 100644 src/main/java/graphql/schema/impl/ShallowTypeRefCollector.java create mode 100644 src/test/groovy/graphql/schema/FastBuilderTest.groovy diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index da7a2ccde..2af49f569 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -5,6 +5,7 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import graphql.Assert; +import graphql.AssertException; import graphql.Directives; import graphql.DirectivesUtil; import graphql.Internal; @@ -15,18 +16,22 @@ import graphql.language.SchemaExtensionDefinition; import graphql.schema.impl.GraphQLTypeCollectingVisitor; import graphql.schema.impl.SchemaUtil; +import graphql.schema.impl.ShallowTypeRefCollector; import graphql.schema.validation.InvalidSchemaException; import graphql.schema.validation.SchemaValidationError; import graphql.schema.validation.SchemaValidator; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import java.util.ArrayList; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.function.Consumer; import static graphql.Assert.assertNotNull; @@ -160,6 +165,37 @@ public GraphQLSchema(BuilderWithoutTypes builder) { this.codeRegistry = builder.codeRegistry; } + /** + * Constructor for FastBuilder - assembles the schema from precomputed fields. + * Performs NO traversals, NO reference replacement, and NO validation. + */ + @Internal + private GraphQLSchema(FastBuilder fastBuilder, + ImmutableMap typeMap, + ImmutableList directives, + ImmutableList schemaDirectives, + ImmutableList schemaAppliedDirectives, + ImmutableMap> interfaceNameToObjectTypes, + ImmutableMap> interfaceNameToObjectTypeNames, + GraphQLCodeRegistry codeRegistry) { + this.queryType = fastBuilder.queryType; + this.mutationType = fastBuilder.mutationType; + this.subscriptionType = fastBuilder.subscriptionType; + this.introspectionSchemaType = fastBuilder.introspectionSchemaType; + this.additionalTypes = ImmutableSet.of(); // Not used in FastBuilder path + this.introspectionSchemaField = Introspection.buildSchemaField(fastBuilder.introspectionSchemaType); + this.introspectionTypeField = Introspection.buildTypeField(fastBuilder.introspectionSchemaType); + this.directiveDefinitionsHolder = new DirectivesUtil.DirectivesHolder(directives, emptyList()); + this.schemaAppliedDirectivesHolder = new DirectivesUtil.DirectivesHolder(schemaDirectives, schemaAppliedDirectives); + this.definition = fastBuilder.definition; + this.extensionDefinitions = nonNullCopyOf(fastBuilder.extensionDefinitions); + this.description = fastBuilder.description; + this.codeRegistry = codeRegistry; + this.typeMap = typeMap; + this.interfaceNameToObjectTypes = interfaceNameToObjectTypes; + this.interfaceNameToObjectTypeNames = interfaceNameToObjectTypeNames; + } + private static GraphQLDirective[] schemaDirectivesArray(GraphQLSchema existingSchema) { return existingSchema.schemaAppliedDirectivesHolder.getDirectives().toArray(new GraphQLDirective[0]); } @@ -1003,4 +1039,347 @@ private GraphQLSchema validateSchema(GraphQLSchema graphQLSchema) { return graphQLSchema; } } + + /** + * A high-performance schema builder that avoids all full-schema traversals performed by + * {@link GraphQLSchema.Builder#build()}. It is intended for constructing schemas, especially + * large or deeply nested schemas. + *

+ * FastBuilder is an "expert mode" builder with stricter assumptions: + *

    + *
  • No clearing/resetting of types or directives
  • + *
  • All named types must be explicitly provided via {@link #additionalType(GraphQLType)}
  • + *
  • Incremental work only: when a type or directive is added, only that node is examined
  • + *
  • No schema traversals except shallow scans for local type references
  • + *
  • Validation is optional, off by default
  • + *
+ *

+ * Performance is gained by eliminating: + *

    + *
  1. Full traversal for code-registry wiring
  2. + *
  3. Full traversal for type-reference replacement
  4. + *
  5. Full traversal for validation (if disabled)
  6. + *
+ */ + @PublicApi + @NullMarked + public static final class FastBuilder { + // Fields consumed by the private constructor + private GraphQLObjectType queryType; + private @Nullable GraphQLObjectType mutationType; + private @Nullable GraphQLObjectType subscriptionType; + private GraphQLObjectType introspectionSchemaType; + private final Map typeMap = new LinkedHashMap<>(); + private final Map directiveMap = new LinkedHashMap<>(); + private final List schemaDirectives = new ArrayList<>(); + private final List schemaAppliedDirectives = new ArrayList<>(); + private final Map> interfacesToImplementations = new LinkedHashMap<>(); + private @Nullable String description; + private @Nullable SchemaDefinition definition; + private @Nullable List extensionDefinitions; + + // Additional fields for building + private final GraphQLCodeRegistry.Builder codeRegistryBuilder; + private final ShallowTypeRefCollector shallowTypeRefCollector = new ShallowTypeRefCollector(); + private boolean validationEnabled = false; + + /** + * Creates a new FastBuilder with the given code registry builder and root types. + * + * @param codeRegistryBuilder the code registry builder (required) + * @param queryType the query type (required) + * @param mutationType the mutation type (optional, may be null) + * @param subscriptionType the subscription type (optional, may be null) + */ + public FastBuilder(GraphQLCodeRegistry.Builder codeRegistryBuilder, + GraphQLObjectType queryType, + @Nullable GraphQLObjectType mutationType, + @Nullable GraphQLObjectType subscriptionType) { + this.codeRegistryBuilder = assertNotNull(codeRegistryBuilder, () -> "codeRegistryBuilder can't be null"); + this.queryType = assertNotNull(queryType, () -> "queryType can't be null"); + this.mutationType = mutationType; + this.subscriptionType = subscriptionType; + this.introspectionSchemaType = Introspection.__Schema; + + // Add introspection code to the registry + Introspection.addCodeForIntrospectionTypes(codeRegistryBuilder); + + // Add root types + additionalType(queryType); + if (mutationType != null) { + additionalType(mutationType); + } + if (subscriptionType != null) { + additionalType(subscriptionType); + } + } + + /** + * Adds a type to the schema. The type must be a named type (not a wrapper like List or NonNull). + * + * @param type the type to add + * @return this builder for chaining + */ + public FastBuilder additionalType(GraphQLType type) { + if (type == null) { + return this; + } + + // Unwrap to named type + GraphQLUnmodifiedType unwrapped = GraphQLTypeUtil.unwrapAll(type); + if (!(unwrapped instanceof GraphQLNamedType)) { + return this; + } + GraphQLNamedType namedType = (GraphQLNamedType) unwrapped; + String name = namedType.getName(); + + // Enforce uniqueness by name + GraphQLNamedType existing = typeMap.get(name); + if (existing != null && existing != namedType) { + throw new AssertException(String.format("Type '%s' already exists with a different instance", name)); + } + + // Skip if already added (same instance) + if (existing != null) { + return this; + } + + // Insert into typeMap + typeMap.put(name, namedType); + + // Shallow scan via ShallowTypeRefCollector + shallowTypeRefCollector.handleTypeDef(namedType); + + // Code registry wiring will be added in Phase 6 for object types + // Interface->implementations map will be added in Phase 6 + + return this; + } + + /** + * Adds multiple types to the schema. + * + * @param types the types to add + * @return this builder for chaining + */ + public FastBuilder additionalTypes(Collection types) { + if (types != null) { + types.forEach(this::additionalType); + } + return this; + } + + /** + * Adds a directive definition to the schema. + * + * @param directive the directive to add + * @return this builder for chaining + */ + public FastBuilder additionalDirective(GraphQLDirective directive) { + // Phase 2+: Will be implemented + return this; + } + + /** + * Adds multiple directive definitions to the schema. + * + * @param directives the directives to add + * @return this builder for chaining + */ + public FastBuilder additionalDirectives(Collection directives) { + if (directives != null) { + directives.forEach(this::additionalDirective); + } + return this; + } + + /** + * Adds a schema-level directive (deprecated, use applied directives). + * + * @param directive the directive to add + * @return this builder for chaining + */ + public FastBuilder withSchemaDirective(GraphQLDirective directive) { + if (directive != null) { + schemaDirectives.add(directive); + } + return this; + } + + /** + * Adds multiple schema-level directives. + * + * @param directives the directives to add + * @return this builder for chaining + */ + public FastBuilder withSchemaDirectives(Collection directives) { + if (directives != null) { + schemaDirectives.addAll(directives); + } + return this; + } + + /** + * Adds a schema-level applied directive. + * + * @param applied the applied directive to add + * @return this builder for chaining + */ + public FastBuilder withSchemaAppliedDirective(GraphQLAppliedDirective applied) { + if (applied != null) { + schemaAppliedDirectives.add(applied); + } + return this; + } + + /** + * Adds multiple schema-level applied directives. + * + * @param appliedList the applied directives to add + * @return this builder for chaining + */ + public FastBuilder withSchemaAppliedDirectives(Collection appliedList) { + if (appliedList != null) { + schemaAppliedDirectives.addAll(appliedList); + } + return this; + } + + /** + * Sets the schema definition (AST). + * + * @param def the schema definition + * @return this builder for chaining + */ + public FastBuilder definition(SchemaDefinition def) { + this.definition = def; + return this; + } + + /** + * Sets the schema extension definitions (AST). + * + * @param defs the extension definitions + * @return this builder for chaining + */ + public FastBuilder extensionDefinitions(List defs) { + this.extensionDefinitions = defs; + return this; + } + + /** + * Sets the schema description. + * + * @param description the description + * @return this builder for chaining + */ + public FastBuilder description(String description) { + this.description = description; + return this; + } + + /** + * Sets the introspection schema type. + * + * @param type the introspection schema type + * @return this builder for chaining + */ + public FastBuilder introspectionSchemaType(GraphQLObjectType type) { + this.introspectionSchemaType = type; + return this; + } + + /** + * Enables or disables schema validation. + * + * @param enabled true to enable validation, false to disable + * @return this builder for chaining + */ + public FastBuilder withValidation(boolean enabled) { + this.validationEnabled = enabled; + return this; + } + + /** + * Builds the GraphQL schema. + * + * @return the built schema + */ + public GraphQLSchema build() { + // Step 1: Replace type references + shallowTypeRefCollector.replaceTypes(typeMap); + + // Step 2: Add built-in directives if missing + addBuiltInDirectivesIfMissing(); + + // Step 3: Build final immutable objects + ImmutableMap finalTypeMap = buildSortedImmutableTypeMap(); + ImmutableList finalDirectives = buildImmutableDirectives(); + ImmutableList finalSchemaDirectives = ImmutableList.copyOf(schemaDirectives); + ImmutableList finalSchemaAppliedDirectives = ImmutableList.copyOf(schemaAppliedDirectives); + ImmutableMap> finalInterfaceMap = buildImmutableInterfaceMap(); + ImmutableMap> finalInterfaceNameMap = buildInterfaceNameMap(finalInterfaceMap); + GraphQLCodeRegistry finalCodeRegistry = codeRegistryBuilder.build(); + + // Step 4: Create schema via private constructor + GraphQLSchema schema = new GraphQLSchema(this, finalTypeMap, finalDirectives, + finalSchemaDirectives, finalSchemaAppliedDirectives, + finalInterfaceMap, finalInterfaceNameMap, finalCodeRegistry); + + // Step 5: Optional validation + if (validationEnabled) { + Collection errors = new SchemaValidator().validateSchema(schema); + if (!errors.isEmpty()) { + throw new InvalidSchemaException(errors); + } + } + + // Step 6: Return + return schema; + } + + private void addBuiltInDirectivesIfMissing() { + addDirectiveIfMissing(Directives.IncludeDirective); + addDirectiveIfMissing(Directives.SkipDirective); + addDirectiveIfMissing(Directives.DeprecatedDirective); + addDirectiveIfMissing(Directives.SpecifiedByDirective); + addDirectiveIfMissing(Directives.OneOfDirective); + addDirectiveIfMissing(Directives.DeferDirective); + } + + private void addDirectiveIfMissing(GraphQLDirective directive) { + if (!directiveMap.containsKey(directive.getName())) { + directiveMap.put(directive.getName(), directive); + } + } + + private ImmutableMap buildSortedImmutableTypeMap() { + TreeMap sorted = new TreeMap<>(typeMap); + return ImmutableMap.copyOf(sorted); + } + + private ImmutableList buildImmutableDirectives() { + return ImmutableList.copyOf(directiveMap.values()); + } + + private ImmutableMap> buildImmutableInterfaceMap() { + ImmutableMap.Builder> builder = ImmutableMap.builder(); + for (Map.Entry> entry : interfacesToImplementations.entrySet()) { + ImmutableList sortedObjectTypes = ImmutableList.copyOf( + sortTypes(byNameAsc(), entry.getValue())); + builder.put(entry.getKey(), sortedObjectTypes); + } + return builder.build(); + } + + private ImmutableMap> buildInterfaceNameMap( + ImmutableMap> interfaceMap) { + ImmutableMap.Builder> builder = ImmutableMap.builder(); + for (Map.Entry> entry : interfaceMap.entrySet()) { + ImmutableList objectTypeNames = map(entry.getValue(), GraphQLObjectType::getName); + builder.put(entry.getKey(), objectTypeNames); + } + return builder.build(); + } + } } diff --git a/src/main/java/graphql/schema/impl/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/impl/ShallowTypeRefCollector.java new file mode 100644 index 000000000..c20dc49ca --- /dev/null +++ b/src/main/java/graphql/schema/impl/ShallowTypeRefCollector.java @@ -0,0 +1,58 @@ +package graphql.schema.impl; + +import graphql.Internal; +import graphql.schema.GraphQLDirective; +import graphql.schema.GraphQLNamedType; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Collects type-refs found in type- and directive-definitions for later replacement with actual types. + * This class performs shallow scans (no recursive traversal from one type-def to another) and + * collects replacement targets that need their type references resolved. + */ +@Internal +public class ShallowTypeRefCollector { + + // Replacement targets - no common supertype exists for the replacement-target classes, + // so we use Object. Target classes include: GraphQLArgument, GraphQLFieldDefinition, + // GraphQLInputObjectField, GraphQLList, GraphQLNonNull, GraphQLUnionType, + // GraphQLObjectType (for interfaces), GraphQLInterfaceType (for interfaces), + // GraphQLAppliedDirectiveArgument + private final List replaceTargets = new ArrayList<>(); + + /** + * Scan a type definition for type references. + * Called on GraphQL{Object|Input|Scalar|Union|etc}Type - NOT on wrappers or type-refs. + * + * @param type the named type to scan + */ + public void handleTypeDef(GraphQLNamedType type) { + // Phase 1: Scalars without applied directives - nothing to do yet. + // Future phases will scan fields, arguments, interfaces, union members, + // applied directives, etc. + } + + /** + * Scan a directive definition for type references in its arguments. + * + * @param directive the directive definition to scan + */ + public void handleDirective(GraphQLDirective directive) { + // Phase 2+: Will scan directive arguments for type references + } + + /** + * Replace all collected type references with actual types from typeMap. + * After this call, no GraphQLTypeReference should remain in the schema. + * + * @param typeMap the map of type names to actual types + * @throws graphql.AssertException if a referenced type is not found in typeMap + */ + public void replaceTypes(Map typeMap) { + // Phase 1: No type references to replace yet. + // Future phases will iterate replaceTargets and call appropriate replace methods. + } +} diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy new file mode 100644 index 000000000..0682abc38 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -0,0 +1,273 @@ +package graphql.schema + +import graphql.AssertException +import graphql.Scalars +import spock.lang.Specification + +import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLObjectType.newObject +import static graphql.schema.GraphQLScalarType.newScalar + +class FastBuilderTest extends Specification { + + def "scalar type schema matches standard builder"() { + given: "a custom scalar type" + def customScalar = newScalar() + .name("CustomScalar") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a query type using the scalar" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(customScalar)) + .build() + + and: "code registry" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "building with FastBuilder" + def fastSchema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(customScalar) + .build() + + and: "building with standard Builder" + def standardSchema = GraphQLSchema.newSchema() + .query(queryType) + .codeRegistry(codeRegistry.build()) + .additionalType(customScalar) + .build() + + then: "schemas are equivalent" + fastSchema.queryType.name == standardSchema.queryType.name + fastSchema.getType("CustomScalar") != null + fastSchema.getType("CustomScalar").name == standardSchema.getType("CustomScalar").name + // Check that the types in both schemas match (excluding system types added differently) + fastSchema.typeMap.keySet().containsAll(["Query", "CustomScalar"]) + standardSchema.typeMap.keySet().containsAll(["Query", "CustomScalar"]) + } + + def "duplicate type with different instance throws error"() { + given: "two different scalar instances with same name" + def scalar1 = newScalar() + .name("Duplicate") + .coercing(GraphQLString.getCoercing()) + .build() + def scalar2 = newScalar() + .name("Duplicate") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "code registry" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "adding both scalars" + new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(scalar1) + .additionalType(scalar2) + .build() + + then: "error is thrown" + thrown(AssertException) + } + + def "same type instance can be added multiple times"() { + given: "a scalar type" + def scalar = newScalar() + .name("MyScalar") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(scalar)) + .build() + + and: "code registry" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "adding same scalar twice" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(scalar) + .additionalType(scalar) + .build() + + then: "no error and scalar is in schema" + schema.getType("MyScalar") != null + } + + def "null type is safely ignored"() { + given: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "code registry" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "adding null type" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(null) + .build() + + then: "no error" + schema.queryType.name == "Query" + } + + def "built-in directives are added automatically"() { + given: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "code registry" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "building schema" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .build() + + then: "built-in directives are present" + schema.getDirective("skip") != null + schema.getDirective("include") != null + schema.getDirective("deprecated") != null + schema.getDirective("specifiedBy") != null + schema.getDirective("oneOf") != null + schema.getDirective("defer") != null + } + + def "query type is required"() { + when: "creating FastBuilder with null query type" + new GraphQLSchema.FastBuilder(GraphQLCodeRegistry.newCodeRegistry(), null, null, null) + + then: "error is thrown" + thrown(AssertException) + } + + def "code registry builder is required"() { + given: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "creating FastBuilder with null code registry" + new GraphQLSchema.FastBuilder(null, queryType, null, null) + + then: "error is thrown" + thrown(AssertException) + } + + def "mutation and subscription types are optional"() { + given: "query, mutation, and subscription types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def mutationType = newObject() + .name("Mutation") + .field(newFieldDefinition() + .name("setValue") + .type(GraphQLString)) + .build() + + def subscriptionType = newObject() + .name("Subscription") + .field(newFieldDefinition() + .name("valueChanged") + .type(GraphQLString)) + .build() + + and: "code registry" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "building schema with all root types" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, mutationType, subscriptionType) + .build() + + then: "all root types are present" + schema.queryType.name == "Query" + schema.mutationType.name == "Mutation" + schema.subscriptionType.name == "Subscription" + schema.isSupportingMutations() + schema.isSupportingSubscriptions() + } + + def "schema description can be set"() { + given: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "code registry" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "building schema with description" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .description("Test schema description") + .build() + + then: "description is set" + schema.description == "Test schema description" + } + + def "additionalTypes accepts collection"() { + given: "multiple scalar types" + def scalar1 = newScalar() + .name("Scalar1") + .coercing(GraphQLString.getCoercing()) + .build() + def scalar2 = newScalar() + .name("Scalar2") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "code registry" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "adding types as collection" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalTypes([scalar1, scalar2]) + .build() + + then: "both types are in schema" + schema.getType("Scalar1") != null + schema.getType("Scalar2") != null + } +} From 0f81a9c4aa0a36c5ffb5cceb23a04731720b59ad Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sat, 6 Dec 2025 21:34:41 +0000 Subject: [PATCH 03/25] Phase 2: Add directive support with type reference resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement directive handling in FastBuilder with support for type references in directive arguments: - Move ShallowTypeRefCollector to graphql.schema package (for access to package-private replaceType methods) - Implement additionalDirective() with duplicate detection - Implement ShallowTypeRefCollector.handleDirective() to scan arguments - Implement type reference resolution for directive arguments - Support List and NonNull wrapped type references - Throw AssertException for missing type references - Add comprehensive tests for directive type reference resolution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/graphql/schema/GraphQLSchema.java | 17 +- .../schema/ShallowTypeRefCollector.java | 136 +++++++++ .../schema/impl/ShallowTypeRefCollector.java | 58 ---- .../graphql/schema/FastBuilderTest.groovy | 262 ++++++++++++++++++ 4 files changed, 413 insertions(+), 60 deletions(-) create mode 100644 src/main/java/graphql/schema/ShallowTypeRefCollector.java delete mode 100644 src/main/java/graphql/schema/impl/ShallowTypeRefCollector.java diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 2af49f569..61cba5943 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -16,7 +16,6 @@ import graphql.language.SchemaExtensionDefinition; import graphql.schema.impl.GraphQLTypeCollectingVisitor; import graphql.schema.impl.SchemaUtil; -import graphql.schema.impl.ShallowTypeRefCollector; import graphql.schema.validation.InvalidSchemaException; import graphql.schema.validation.SchemaValidationError; import graphql.schema.validation.SchemaValidator; @@ -1176,7 +1175,21 @@ public FastBuilder additionalTypes(Collection types) { * @return this builder for chaining */ public FastBuilder additionalDirective(GraphQLDirective directive) { - // Phase 2+: Will be implemented + if (directive == null) { + return this; + } + + String name = directive.getName(); + GraphQLDirective existing = directiveMap.get(name); + if (existing != null && existing != directive) { + throw new AssertException(String.format("Directive '%s' already exists with a different instance", name)); + } + + if (existing == null) { + directiveMap.put(name, directive); + shallowTypeRefCollector.handleDirective(directive); + } + return this; } diff --git a/src/main/java/graphql/schema/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/ShallowTypeRefCollector.java new file mode 100644 index 000000000..10fe9aa8d --- /dev/null +++ b/src/main/java/graphql/schema/ShallowTypeRefCollector.java @@ -0,0 +1,136 @@ +package graphql.schema; + +import graphql.AssertException; +import graphql.Internal; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Collects type-refs found in type- and directive-definitions for later replacement with actual types. + * This class performs shallow scans (no recursive traversal from one type-def to another) and + * collects replacement targets that need their type references resolved. + */ +@Internal +public class ShallowTypeRefCollector { + + // Replacement targets - no common supertype exists for the replacement-target classes, + // so we use Object. Target classes include: GraphQLArgument, GraphQLFieldDefinition, + // GraphQLInputObjectField, GraphQLList, GraphQLNonNull, GraphQLUnionType, + // GraphQLObjectType (for interfaces), GraphQLInterfaceType (for interfaces), + // GraphQLAppliedDirectiveArgument + private final List replaceTargets = new ArrayList<>(); + + /** + * Scan a type definition for type references. + * Called on GraphQL{Object|Input|Scalar|Union|etc}Type - NOT on wrappers or type-refs. + * + * @param type the named type to scan + */ + public void handleTypeDef(GraphQLNamedType type) { + // Phase 1: Scalars without applied directives - nothing to do yet. + // Future phases will scan fields, arguments, interfaces, union members, + // applied directives, etc. + } + + /** + * Scan a directive definition for type references in its arguments. + * + * @param directive the directive definition to scan + */ + public void handleDirective(GraphQLDirective directive) { + for (GraphQLArgument argument : directive.getArguments()) { + scanArgumentType(argument); + } + } + + /** + * Scan an argument's type for type references. + */ + private void scanArgumentType(GraphQLArgument argument) { + if (containsTypeReference(argument.getType())) { + replaceTargets.add(argument); + } + } + + /** + * Check if a type contains a type reference (possibly wrapped in List/NonNull). + */ + private boolean containsTypeReference(GraphQLType type) { + GraphQLType unwrapped = type; + while (unwrapped instanceof GraphQLNonNull) { + unwrapped = ((GraphQLNonNull) unwrapped).getWrappedType(); + } + while (unwrapped instanceof GraphQLList) { + unwrapped = ((GraphQLList) unwrapped).getWrappedType(); + while (unwrapped instanceof GraphQLNonNull) { + unwrapped = ((GraphQLNonNull) unwrapped).getWrappedType(); + } + } + return unwrapped instanceof GraphQLTypeReference; + } + + /** + * Replace all collected type references with actual types from typeMap. + * After this call, no GraphQLTypeReference should remain in the schema. + * + * @param typeMap the map of type names to actual types + * @throws graphql.AssertException if a referenced type is not found in typeMap + */ + public void replaceTypes(Map typeMap) { + for (Object target : replaceTargets) { + if (target instanceof GraphQLArgument) { + replaceArgumentType((GraphQLArgument) target, typeMap); + } + // Future phases will handle other target types + } + } + + private void replaceArgumentType(GraphQLArgument argument, Map typeMap) { + GraphQLInputType resolvedType = resolveInputType(argument.getType(), typeMap); + argument.replaceType(resolvedType); + } + + /** + * Resolve an input type, replacing any type references with actual types. + * Handles List and NonNull wrappers recursively. + */ + private GraphQLInputType resolveInputType(GraphQLInputType type, Map typeMap) { + if (type instanceof GraphQLNonNull) { + GraphQLNonNull nonNull = (GraphQLNonNull) type; + GraphQLType wrappedType = nonNull.getWrappedType(); + if (wrappedType instanceof GraphQLInputType) { + GraphQLInputType resolvedWrapped = resolveInputType((GraphQLInputType) wrappedType, typeMap); + if (resolvedWrapped != wrappedType) { + nonNull.replaceType(resolvedWrapped); + } + } + return type; + } + if (type instanceof GraphQLList) { + GraphQLList list = (GraphQLList) type; + GraphQLType wrappedType = list.getWrappedType(); + if (wrappedType instanceof GraphQLInputType) { + GraphQLInputType resolvedWrapped = resolveInputType((GraphQLInputType) wrappedType, typeMap); + if (resolvedWrapped != wrappedType) { + list.replaceType(resolvedWrapped); + } + } + return type; + } + if (type instanceof GraphQLTypeReference) { + String typeName = ((GraphQLTypeReference) type).getName(); + GraphQLNamedType resolved = typeMap.get(typeName); + if (resolved == null) { + throw new AssertException(String.format("Type '%s' not found in schema", typeName)); + } + if (!(resolved instanceof GraphQLInputType)) { + throw new AssertException(String.format("Type '%s' is not an input type", typeName)); + } + return (GraphQLInputType) resolved; + } + // Already a concrete type, return as-is + return type; + } +} diff --git a/src/main/java/graphql/schema/impl/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/impl/ShallowTypeRefCollector.java deleted file mode 100644 index c20dc49ca..000000000 --- a/src/main/java/graphql/schema/impl/ShallowTypeRefCollector.java +++ /dev/null @@ -1,58 +0,0 @@ -package graphql.schema.impl; - -import graphql.Internal; -import graphql.schema.GraphQLDirective; -import graphql.schema.GraphQLNamedType; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/** - * Collects type-refs found in type- and directive-definitions for later replacement with actual types. - * This class performs shallow scans (no recursive traversal from one type-def to another) and - * collects replacement targets that need their type references resolved. - */ -@Internal -public class ShallowTypeRefCollector { - - // Replacement targets - no common supertype exists for the replacement-target classes, - // so we use Object. Target classes include: GraphQLArgument, GraphQLFieldDefinition, - // GraphQLInputObjectField, GraphQLList, GraphQLNonNull, GraphQLUnionType, - // GraphQLObjectType (for interfaces), GraphQLInterfaceType (for interfaces), - // GraphQLAppliedDirectiveArgument - private final List replaceTargets = new ArrayList<>(); - - /** - * Scan a type definition for type references. - * Called on GraphQL{Object|Input|Scalar|Union|etc}Type - NOT on wrappers or type-refs. - * - * @param type the named type to scan - */ - public void handleTypeDef(GraphQLNamedType type) { - // Phase 1: Scalars without applied directives - nothing to do yet. - // Future phases will scan fields, arguments, interfaces, union members, - // applied directives, etc. - } - - /** - * Scan a directive definition for type references in its arguments. - * - * @param directive the directive definition to scan - */ - public void handleDirective(GraphQLDirective directive) { - // Phase 2+: Will scan directive arguments for type references - } - - /** - * Replace all collected type references with actual types from typeMap. - * After this call, no GraphQLTypeReference should remain in the schema. - * - * @param typeMap the map of type names to actual types - * @throws graphql.AssertException if a referenced type is not found in typeMap - */ - public void replaceTypes(Map typeMap) { - // Phase 1: No type references to replace yet. - // Future phases will iterate replaceTargets and call appropriate replace methods. - } -} diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index 0682abc38..6d71b2429 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -2,12 +2,16 @@ package graphql.schema import graphql.AssertException import graphql.Scalars +import graphql.introspection.Introspection import spock.lang.Specification import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLArgument.newArgument +import static graphql.schema.GraphQLDirective.newDirective import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition import static graphql.schema.GraphQLObjectType.newObject import static graphql.schema.GraphQLScalarType.newScalar +import static graphql.schema.GraphQLTypeReference.typeRef class FastBuilderTest extends Specification { @@ -270,4 +274,262 @@ class FastBuilderTest extends Specification { schema.getType("Scalar1") != null schema.getType("Scalar2") != null } + + // ==================== Phase 2: Directives with Scalar Arguments ==================== + + def "directive with type reference argument resolves correctly"() { + given: "a custom scalar" + def customScalar = newScalar() + .name("Foo") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive with type reference argument" + def directive = newDirective() + .name("bar") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("arg") + .type(typeRef("Foo"))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(customScalar)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(customScalar) + .additionalDirective(directive) + .build() + + then: "directive argument type is resolved" + def resolvedDirective = schema.getDirective("bar") + resolvedDirective != null + resolvedDirective.getArgument("arg").getType() == customScalar + } + + def "directive with NonNull wrapped type reference resolves correctly"() { + given: "a custom scalar" + def customScalar = newScalar() + .name("MyScalar") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive with NonNull type reference argument" + def directive = newDirective() + .name("myDirective") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("arg") + .type(GraphQLNonNull.nonNull(typeRef("MyScalar")))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(customScalar) + .additionalDirective(directive) + .build() + + then: "directive argument type is resolved with NonNull wrapper" + def resolvedDirective = schema.getDirective("myDirective") + def argType = resolvedDirective.getArgument("arg").getType() + argType instanceof GraphQLNonNull + ((GraphQLNonNull) argType).getWrappedType() == customScalar + } + + def "directive with List wrapped type reference resolves correctly"() { + given: "a custom scalar" + def customScalar = newScalar() + .name("ListScalar") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive with List type reference argument" + def directive = newDirective() + .name("listDirective") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("args") + .type(GraphQLList.list(typeRef("ListScalar")))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(customScalar) + .additionalDirective(directive) + .build() + + then: "directive argument type is resolved with List wrapper" + def resolvedDirective = schema.getDirective("listDirective") + def argType = resolvedDirective.getArgument("args").getType() + argType instanceof GraphQLList + ((GraphQLList) argType).getWrappedType() == customScalar + } + + def "missing type reference throws error"() { + given: "a directive referencing non-existent type" + def directive = newDirective() + .name("bar") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("arg") + .type(typeRef("NonExistent"))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building" + new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalDirective(directive) + .build() + + then: "error for missing type" + thrown(AssertException) + } + + def "duplicate directive with different instance throws error"() { + given: "two different directive instances with same name" + def directive1 = newDirective() + .name("duplicate") + .validLocation(Introspection.DirectiveLocation.FIELD) + .build() + def directive2 = newDirective() + .name("duplicate") + .validLocation(Introspection.DirectiveLocation.OBJECT) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "adding both directives" + new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalDirective(directive1) + .additionalDirective(directive2) + .build() + + then: "error is thrown" + thrown(AssertException) + } + + def "same directive instance can be added multiple times"() { + given: "a directive" + def directive = newDirective() + .name("myDir") + .validLocation(Introspection.DirectiveLocation.FIELD) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "adding same directive twice" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalDirective(directive) + .additionalDirective(directive) + .build() + + then: "no error and directive is in schema" + schema.getDirective("myDir") != null + } + + def "additionalDirectives accepts collection"() { + given: "multiple directives" + def directive1 = newDirective() + .name("dir1") + .validLocation(Introspection.DirectiveLocation.FIELD) + .build() + def directive2 = newDirective() + .name("dir2") + .validLocation(Introspection.DirectiveLocation.OBJECT) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "adding directives as collection" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalDirectives([directive1, directive2]) + .build() + + then: "both directives are in schema" + schema.getDirective("dir1") != null + schema.getDirective("dir2") != null + } + + def "directive argument with concrete type (no type reference) works"() { + given: "a directive with concrete type argument" + def directive = newDirective() + .name("withString") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("msg") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalDirective(directive) + .build() + + then: "directive argument type remains unchanged" + def resolvedDirective = schema.getDirective("withString") + resolvedDirective.getArgument("msg").getType() == GraphQLString + } } From e39bf208420d2a235c9b30837bfc2cdda1fcded3 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sat, 6 Dec 2025 21:35:44 +0000 Subject: [PATCH 04/25] Phase 3: Add enumeration type support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enumeration types work with the existing additionalType() implementation. Add tests to verify: - Enum types can be added to schema - Enum type matches standard builder output - Directive arguments can reference enum types via GraphQLTypeReference 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../graphql/schema/FastBuilderTest.groovy | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index 6d71b2429..2f4096819 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -8,6 +8,7 @@ import spock.lang.Specification import static graphql.Scalars.GraphQLString import static graphql.schema.GraphQLArgument.newArgument import static graphql.schema.GraphQLDirective.newDirective +import static graphql.schema.GraphQLEnumType.newEnum import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition import static graphql.schema.GraphQLObjectType.newObject import static graphql.schema.GraphQLScalarType.newScalar @@ -532,4 +533,116 @@ class FastBuilderTest extends Specification { def resolvedDirective = schema.getDirective("withString") resolvedDirective.getArgument("msg").getType() == GraphQLString } + + // ==================== Phase 3: Enumeration Types ==================== + + def "enum type can be added to schema"() { + given: "an enum type" + def statusEnum = newEnum() + .name("Status") + .value("ACTIVE") + .value("INACTIVE") + .value("PENDING") + .build() + + and: "a query type using the enum" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("status") + .type(statusEnum)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(statusEnum) + .build() + + then: "enum type is in schema" + def resolvedEnum = schema.getType("Status") + resolvedEnum instanceof GraphQLEnumType + (resolvedEnum as GraphQLEnumType).values.size() == 3 + (resolvedEnum as GraphQLEnumType).getValue("ACTIVE") != null + (resolvedEnum as GraphQLEnumType).getValue("INACTIVE") != null + (resolvedEnum as GraphQLEnumType).getValue("PENDING") != null + } + + def "enum type matches standard builder"() { + given: "an enum type" + def statusEnum = newEnum() + .name("Status") + .value("ACTIVE") + .value("INACTIVE") + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("status") + .type(statusEnum)) + .build() + + and: "code registry" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "building with FastBuilder" + def fastSchema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(statusEnum) + .build() + + and: "building with standard Builder" + def standardSchema = GraphQLSchema.newSchema() + .query(queryType) + .codeRegistry(codeRegistry.build()) + .additionalType(statusEnum) + .build() + + then: "schemas have equivalent enum types" + def fastEnum = fastSchema.getType("Status") as GraphQLEnumType + def standardEnum = standardSchema.getType("Status") as GraphQLEnumType + fastEnum.values.size() == standardEnum.values.size() + fastEnum.getValue("ACTIVE") != null + fastEnum.getValue("INACTIVE") != null + } + + def "directive argument with enum type reference resolves correctly"() { + given: "an enum type" + def levelEnum = newEnum() + .name("LogLevel") + .value("DEBUG") + .value("INFO") + .value("WARN") + .value("ERROR") + .build() + + and: "a directive with enum type reference argument" + def directive = newDirective() + .name("log") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("level") + .type(typeRef("LogLevel"))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(levelEnum) + .additionalDirective(directive) + .build() + + then: "directive argument type is resolved to enum" + def resolvedDirective = schema.getDirective("log") + resolvedDirective.getArgument("level").getType() == levelEnum + } } From e473ca8723bc235d8be3afc39bfff86692d0e0fa Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sat, 6 Dec 2025 21:37:12 +0000 Subject: [PATCH 05/25] Phase 4: Add input object type support with type reference resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend ShallowTypeRefCollector to handle GraphQLInputObjectType: - Scan input object fields for type references - Implement replaceInputFieldType() for field type resolution - Support nested input types via type references - Support List and NonNull wrapped type references in input fields - Add comprehensive tests for input object type handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../schema/ShallowTypeRefCollector.java | 23 +- .../graphql/schema/FastBuilderTest.groovy | 249 ++++++++++++++++++ 2 files changed, 269 insertions(+), 3 deletions(-) diff --git a/src/main/java/graphql/schema/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/ShallowTypeRefCollector.java index 10fe9aa8d..75dfab891 100644 --- a/src/main/java/graphql/schema/ShallowTypeRefCollector.java +++ b/src/main/java/graphql/schema/ShallowTypeRefCollector.java @@ -29,9 +29,19 @@ public class ShallowTypeRefCollector { * @param type the named type to scan */ public void handleTypeDef(GraphQLNamedType type) { - // Phase 1: Scalars without applied directives - nothing to do yet. - // Future phases will scan fields, arguments, interfaces, union members, - // applied directives, etc. + if (type instanceof GraphQLInputObjectType) { + handleInputObjectType((GraphQLInputObjectType) type); + } + // Future phases will handle: GraphQLObjectType, GraphQLInterfaceType, + // GraphQLUnionType, applied directives on types + } + + private void handleInputObjectType(GraphQLInputObjectType inputType) { + for (GraphQLInputObjectField field : inputType.getFieldDefinitions()) { + if (containsTypeReference(field.getType())) { + replaceTargets.add(field); + } + } } /** @@ -82,11 +92,18 @@ public void replaceTypes(Map typeMap) { for (Object target : replaceTargets) { if (target instanceof GraphQLArgument) { replaceArgumentType((GraphQLArgument) target, typeMap); + } else if (target instanceof GraphQLInputObjectField) { + replaceInputFieldType((GraphQLInputObjectField) target, typeMap); } // Future phases will handle other target types } } + private void replaceInputFieldType(GraphQLInputObjectField field, Map typeMap) { + GraphQLInputType resolvedType = resolveInputType(field.getType(), typeMap); + field.replaceType(resolvedType); + } + private void replaceArgumentType(GraphQLArgument argument, Map typeMap) { GraphQLInputType resolvedType = resolveInputType(argument.getType(), typeMap); argument.replaceType(resolvedType); diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index 2f4096819..e6a68ec46 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -10,6 +10,8 @@ import static graphql.schema.GraphQLArgument.newArgument import static graphql.schema.GraphQLDirective.newDirective import static graphql.schema.GraphQLEnumType.newEnum import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLInputObjectField.newInputObjectField +import static graphql.schema.GraphQLInputObjectType.newInputObject import static graphql.schema.GraphQLObjectType.newObject import static graphql.schema.GraphQLScalarType.newScalar import static graphql.schema.GraphQLTypeReference.typeRef @@ -645,4 +647,251 @@ class FastBuilderTest extends Specification { def resolvedDirective = schema.getDirective("log") resolvedDirective.getArgument("level").getType() == levelEnum } + + // ==================== Phase 4: Input Object Types ==================== + + def "input object type can be added to schema"() { + given: "an input object type" + def inputType = newInputObject() + .name("CreateUserInput") + .field(newInputObjectField() + .name("name") + .type(GraphQLString)) + .field(newInputObjectField() + .name("email") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("createUser") + .argument(newArgument() + .name("input") + .type(inputType)) + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(inputType) + .build() + + then: "input type is in schema" + def resolvedInput = schema.getType("CreateUserInput") + resolvedInput instanceof GraphQLInputObjectType + (resolvedInput as GraphQLInputObjectType).getField("name") != null + (resolvedInput as GraphQLInputObjectType).getField("email") != null + } + + def "input object type with type reference field resolves correctly"() { + given: "a custom scalar" + def customScalar = newScalar() + .name("DateTime") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "an input object type with type reference" + def inputType = newInputObject() + .name("EventInput") + .field(newInputObjectField() + .name("name") + .type(GraphQLString)) + .field(newInputObjectField() + .name("startDate") + .type(typeRef("DateTime"))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("createEvent") + .argument(newArgument() + .name("input") + .type(inputType)) + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(customScalar) + .additionalType(inputType) + .build() + + then: "input field type is resolved" + def resolvedInput = schema.getType("EventInput") as GraphQLInputObjectType + resolvedInput.getField("startDate").getType() == customScalar + } + + def "input object type with nested input object type reference resolves correctly"() { + given: "an address input type" + def addressInput = newInputObject() + .name("AddressInput") + .field(newInputObjectField() + .name("street") + .type(GraphQLString)) + .field(newInputObjectField() + .name("city") + .type(GraphQLString)) + .build() + + and: "a user input type with type reference to address" + def userInput = newInputObject() + .name("UserInput") + .field(newInputObjectField() + .name("name") + .type(GraphQLString)) + .field(newInputObjectField() + .name("address") + .type(typeRef("AddressInput"))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("createUser") + .argument(newArgument() + .name("input") + .type(userInput)) + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(addressInput) + .additionalType(userInput) + .build() + + then: "nested input field type is resolved" + def resolvedUser = schema.getType("UserInput") as GraphQLInputObjectType + resolvedUser.getField("address").getType() == addressInput + } + + def "input object type with NonNull wrapped type reference resolves correctly"() { + given: "an enum type" + def statusEnum = newEnum() + .name("Status") + .value("ACTIVE") + .value("INACTIVE") + .build() + + and: "an input type with NonNull type reference" + def inputType = newInputObject() + .name("UpdateInput") + .field(newInputObjectField() + .name("status") + .type(GraphQLNonNull.nonNull(typeRef("Status")))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("update") + .argument(newArgument() + .name("input") + .type(inputType)) + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(statusEnum) + .additionalType(inputType) + .build() + + then: "input field type is resolved with NonNull wrapper" + def resolvedInput = schema.getType("UpdateInput") as GraphQLInputObjectType + def fieldType = resolvedInput.getField("status").getType() + fieldType instanceof GraphQLNonNull + ((GraphQLNonNull) fieldType).getWrappedType() == statusEnum + } + + def "input object type with List wrapped type reference resolves correctly"() { + given: "a custom scalar" + def tagScalar = newScalar() + .name("Tag") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "an input type with List type reference" + def inputType = newInputObject() + .name("PostInput") + .field(newInputObjectField() + .name("title") + .type(GraphQLString)) + .field(newInputObjectField() + .name("tags") + .type(GraphQLList.list(typeRef("Tag")))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("createPost") + .argument(newArgument() + .name("input") + .type(inputType)) + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(tagScalar) + .additionalType(inputType) + .build() + + then: "input field type is resolved with List wrapper" + def resolvedInput = schema.getType("PostInput") as GraphQLInputObjectType + def fieldType = resolvedInput.getField("tags").getType() + fieldType instanceof GraphQLList + ((GraphQLList) fieldType).getWrappedType() == tagScalar + } + + def "directive argument can reference input object type"() { + given: "an input object type" + def configInput = newInputObject() + .name("ConfigInput") + .field(newInputObjectField() + .name("enabled") + .type(Scalars.GraphQLBoolean)) + .build() + + and: "a directive with input type reference argument" + def directive = newDirective() + .name("config") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("settings") + .type(typeRef("ConfigInput"))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(configInput) + .additionalDirective(directive) + .build() + + then: "directive argument type is resolved to input type" + def resolvedDirective = schema.getDirective("config") + resolvedDirective.getArgument("settings").getType() == configInput + } } From d09a7638ca77a792b7c735340d45780389bf5eb5 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sat, 6 Dec 2025 21:39:01 +0000 Subject: [PATCH 06/25] Phase 5: Add applied directive support with type reference resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend ShallowTypeRefCollector to handle applied directives: - Add scanAppliedDirectives() method for scanning applied directive args - Scan applied directives on directive container types (enum, scalar, etc.) - Scan applied directives on input object fields - Implement replaceAppliedDirectiveArgumentType() for type resolution - Update FastBuilder.withSchemaAppliedDirective() to scan for type refs - Add comprehensive tests for applied directive type reference resolution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/graphql/schema/GraphQLSchema.java | 3 + .../schema/ShallowTypeRefCollector.java | 32 ++- .../graphql/schema/FastBuilderTest.groovy | 192 ++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 61cba5943..6bb9f5885 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -42,6 +42,7 @@ import static graphql.schema.GraphqlTypeComparators.byNameAsc; import static graphql.schema.GraphqlTypeComparators.sortTypes; import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; /** * The schema represents the combined type system of the graphql engine. This is how the engine knows @@ -1241,6 +1242,8 @@ public FastBuilder withSchemaDirectives(Collection d public FastBuilder withSchemaAppliedDirective(GraphQLAppliedDirective applied) { if (applied != null) { schemaAppliedDirectives.add(applied); + // Scan applied directive arguments for type references + shallowTypeRefCollector.scanAppliedDirectives(singletonList(applied)); } return this; } diff --git a/src/main/java/graphql/schema/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/ShallowTypeRefCollector.java index 75dfab891..a611e3af6 100644 --- a/src/main/java/graphql/schema/ShallowTypeRefCollector.java +++ b/src/main/java/graphql/schema/ShallowTypeRefCollector.java @@ -32,8 +32,12 @@ public void handleTypeDef(GraphQLNamedType type) { if (type instanceof GraphQLInputObjectType) { handleInputObjectType((GraphQLInputObjectType) type); } - // Future phases will handle: GraphQLObjectType, GraphQLInterfaceType, - // GraphQLUnionType, applied directives on types + // Scan applied directives on all directive container types + if (type instanceof GraphQLDirectiveContainer) { + scanAppliedDirectives(((GraphQLDirectiveContainer) type).getAppliedDirectives()); + } + // Future phases will handle: GraphQLObjectType fields, GraphQLInterfaceType fields, + // GraphQLUnionType members, GraphQLObjectType/InterfaceType interfaces } private void handleInputObjectType(GraphQLInputObjectType inputType) { @@ -41,6 +45,23 @@ private void handleInputObjectType(GraphQLInputObjectType inputType) { if (containsTypeReference(field.getType())) { replaceTargets.add(field); } + // Scan applied directives on input fields + scanAppliedDirectives(field.getAppliedDirectives()); + } + } + + /** + * Scan applied directives for type references in their arguments. + * + * @param appliedDirectives the applied directives to scan + */ + public void scanAppliedDirectives(List appliedDirectives) { + for (GraphQLAppliedDirective applied : appliedDirectives) { + for (GraphQLAppliedDirectiveArgument arg : applied.getArguments()) { + if (containsTypeReference(arg.getType())) { + replaceTargets.add(arg); + } + } } } @@ -94,11 +115,18 @@ public void replaceTypes(Map typeMap) { replaceArgumentType((GraphQLArgument) target, typeMap); } else if (target instanceof GraphQLInputObjectField) { replaceInputFieldType((GraphQLInputObjectField) target, typeMap); + } else if (target instanceof GraphQLAppliedDirectiveArgument) { + replaceAppliedDirectiveArgumentType((GraphQLAppliedDirectiveArgument) target, typeMap); } // Future phases will handle other target types } } + private void replaceAppliedDirectiveArgumentType(GraphQLAppliedDirectiveArgument arg, Map typeMap) { + GraphQLInputType resolvedType = resolveInputType(arg.getType(), typeMap); + arg.replaceType(resolvedType); + } + private void replaceInputFieldType(GraphQLInputObjectField field, Map typeMap) { GraphQLInputType resolvedType = resolveInputType(field.getType(), typeMap); field.replaceType(resolvedType); diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index e6a68ec46..6f7028f9c 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -8,6 +8,8 @@ import spock.lang.Specification import static graphql.Scalars.GraphQLString import static graphql.schema.GraphQLArgument.newArgument import static graphql.schema.GraphQLDirective.newDirective +import static graphql.schema.GraphQLAppliedDirective.newDirective as newAppliedDirective +import static graphql.schema.GraphQLAppliedDirectiveArgument.newArgument as newAppliedArgument import static graphql.schema.GraphQLEnumType.newEnum import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition import static graphql.schema.GraphQLInputObjectField.newInputObjectField @@ -894,4 +896,194 @@ class FastBuilderTest extends Specification { def resolvedDirective = schema.getDirective("config") resolvedDirective.getArgument("settings").getType() == configInput } + + // ==================== Phase 5: Applied Directives ==================== + + def "schema applied directive with type reference argument resolves correctly"() { + given: "a custom scalar for directive argument" + def configScalar = newScalar() + .name("ConfigValue") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive definition" + def directive = newDirective() + .name("config") + .validLocation(Introspection.DirectiveLocation.SCHEMA) + .argument(newArgument() + .name("value") + .type(configScalar)) + .build() + + and: "an applied directive with type reference" + def appliedDirective = newAppliedDirective() + .name("config") + .argument(newAppliedArgument() + .name("value") + .type(typeRef("ConfigValue")) + .valueProgrammatic("test")) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(configScalar) + .additionalDirective(directive) + .withSchemaAppliedDirective(appliedDirective) + .build() + + then: "applied directive argument type is resolved" + def resolved = schema.getSchemaAppliedDirective("config") + resolved.getArgument("value").getType() == configScalar + } + + def "type with applied directive argument type reference resolves correctly"() { + given: "a custom scalar" + def customScalar = newScalar() + .name("MyScalar") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive definition" + def directive = newDirective() + .name("myDir") + .validLocation(Introspection.DirectiveLocation.ENUM) + .argument(newArgument() + .name("arg") + .type(customScalar)) + .build() + + and: "an applied directive with type reference" + def appliedDirective = newAppliedDirective() + .name("myDir") + .argument(newAppliedArgument() + .name("arg") + .type(typeRef("MyScalar")) + .valueProgrammatic("value")) + .build() + + and: "an enum with the applied directive" + def enumType = newEnum() + .name("Status") + .value("ACTIVE") + .withAppliedDirective(appliedDirective) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("status") + .type(enumType)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(customScalar) + .additionalType(enumType) + .additionalDirective(directive) + .build() + + then: "applied directive argument type on enum is resolved" + def resolvedEnum = schema.getType("Status") as GraphQLEnumType + def resolvedApplied = resolvedEnum.getAppliedDirective("myDir") + resolvedApplied.getArgument("arg").getType() == customScalar + } + + def "input object field with applied directive type reference resolves correctly"() { + given: "a custom scalar" + def customScalar = newScalar() + .name("FieldMeta") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive definition" + def directive = newDirective() + .name("meta") + .validLocation(Introspection.DirectiveLocation.INPUT_FIELD_DEFINITION) + .argument(newArgument() + .name("data") + .type(customScalar)) + .build() + + and: "an applied directive with type reference" + def appliedDirective = newAppliedDirective() + .name("meta") + .argument(newAppliedArgument() + .name("data") + .type(typeRef("FieldMeta")) + .valueProgrammatic("metadata")) + .build() + + and: "an input type with field having applied directive" + def inputType = newInputObject() + .name("MyInput") + .field(newInputObjectField() + .name("field1") + .type(GraphQLString) + .withAppliedDirective(appliedDirective)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("query") + .argument(newArgument() + .name("input") + .type(inputType)) + .type(GraphQLString)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(customScalar) + .additionalType(inputType) + .additionalDirective(directive) + .build() + + then: "applied directive argument type on input field is resolved" + def resolvedInput = schema.getType("MyInput") as GraphQLInputObjectType + def field = resolvedInput.getField("field1") + def resolvedApplied = field.getAppliedDirective("meta") + resolvedApplied.getArgument("data").getType() == customScalar + } + + def "multiple schema applied directives work correctly"() { + given: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "multiple applied directives (no type refs)" + def applied1 = newAppliedDirective() + .name("dir1") + .build() + def applied2 = newAppliedDirective() + .name("dir2") + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .withSchemaAppliedDirectives([applied1, applied2]) + .build() + + then: "both applied directives are in schema" + schema.getSchemaAppliedDirective("dir1") != null + schema.getSchemaAppliedDirective("dir2") != null + } } From 798c7e14c63a596f7b6e9a25afdb3a710d7831b9 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sat, 6 Dec 2025 21:43:00 +0000 Subject: [PATCH 07/25] Phase 6: Object Types - FastBuilder implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extended ShallowTypeRefCollector to handle GraphQLObjectType: - Scans field types for type references - Scans field arguments for type references - Scans applied directives on fields - Scans interfaces for type references - Added resolveOutputType() for output type reference resolution - Added replaceFieldType() for field type replacement - Added ObjectInterfaceReplaceTarget wrapper and replaceObjectInterfaces() - Updated FastBuilder.additionalType() to maintain interface→implementations map - Added comprehensive tests for object type handling: - Field type reference resolution (direct, NonNull, List) - Interface type reference resolution - Interface→implementations map building - Field argument type reference resolution - Applied directive on fields - Error cases for missing types/interfaces 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/graphql/schema/GraphQLSchema.java | 10 +- .../schema/ShallowTypeRefCollector.java | 118 ++++++- .../graphql/schema/FastBuilderTest.groovy | 320 ++++++++++++++++++ 3 files changed, 443 insertions(+), 5 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 6bb9f5885..00272d832 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -1150,8 +1150,14 @@ public FastBuilder additionalType(GraphQLType type) { // Shallow scan via ShallowTypeRefCollector shallowTypeRefCollector.handleTypeDef(namedType); - // Code registry wiring will be added in Phase 6 for object types - // Interface->implementations map will be added in Phase 6 + // For object types, update interface→implementations map + if (namedType instanceof GraphQLObjectType) { + GraphQLObjectType objectType = (GraphQLObjectType) namedType; + for (GraphQLNamedOutputType iface : objectType.getInterfaces()) { + String interfaceName = iface.getName(); + interfacesToImplementations.computeIfAbsent(interfaceName, k -> new ArrayList<>()).add(objectType); + } + } return this; } diff --git a/src/main/java/graphql/schema/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/ShallowTypeRefCollector.java index a611e3af6..f8edb2498 100644 --- a/src/main/java/graphql/schema/ShallowTypeRefCollector.java +++ b/src/main/java/graphql/schema/ShallowTypeRefCollector.java @@ -31,13 +31,53 @@ public class ShallowTypeRefCollector { public void handleTypeDef(GraphQLNamedType type) { if (type instanceof GraphQLInputObjectType) { handleInputObjectType((GraphQLInputObjectType) type); + } else if (type instanceof GraphQLObjectType) { + handleObjectType((GraphQLObjectType) type); } // Scan applied directives on all directive container types if (type instanceof GraphQLDirectiveContainer) { scanAppliedDirectives(((GraphQLDirectiveContainer) type).getAppliedDirectives()); } - // Future phases will handle: GraphQLObjectType fields, GraphQLInterfaceType fields, - // GraphQLUnionType members, GraphQLObjectType/InterfaceType interfaces + // Future phases will handle: GraphQLInterfaceType fields, GraphQLUnionType members + } + + private void handleObjectType(GraphQLObjectType objectType) { + // Scan fields for type references + for (GraphQLFieldDefinition field : objectType.getFieldDefinitions()) { + if (containsTypeReference(field.getType())) { + replaceTargets.add(field); + } + // Scan field arguments + for (GraphQLArgument arg : field.getArguments()) { + scanArgumentType(arg); + } + // Scan applied directives on field + scanAppliedDirectives(field.getAppliedDirectives()); + } + // Scan interfaces for type references + if (hasInterfaceTypeReferences(objectType.getInterfaces())) { + replaceTargets.add(new ObjectInterfaceReplaceTarget(objectType)); + } + } + + private boolean hasInterfaceTypeReferences(List interfaces) { + for (GraphQLNamedOutputType iface : interfaces) { + if (iface instanceof GraphQLTypeReference) { + return true; + } + } + return false; + } + + /** + * Wrapper class to track object types that need interface replacement. + */ + static class ObjectInterfaceReplaceTarget { + final GraphQLObjectType objectType; + + ObjectInterfaceReplaceTarget(GraphQLObjectType objectType) { + this.objectType = objectType; + } } private void handleInputObjectType(GraphQLInputObjectType inputType) { @@ -117,8 +157,12 @@ public void replaceTypes(Map typeMap) { replaceInputFieldType((GraphQLInputObjectField) target, typeMap); } else if (target instanceof GraphQLAppliedDirectiveArgument) { replaceAppliedDirectiveArgumentType((GraphQLAppliedDirectiveArgument) target, typeMap); + } else if (target instanceof GraphQLFieldDefinition) { + replaceFieldType((GraphQLFieldDefinition) target, typeMap); + } else if (target instanceof ObjectInterfaceReplaceTarget) { + replaceObjectInterfaces((ObjectInterfaceReplaceTarget) target, typeMap); } - // Future phases will handle other target types + // Future phases will handle: GraphQLInterfaceType interfaces, GraphQLUnionType members } } @@ -137,6 +181,74 @@ private void replaceArgumentType(GraphQLArgument argument, Map typeMap) { + GraphQLOutputType resolvedType = resolveOutputType(field.getType(), typeMap); + field.replaceType(resolvedType); + } + + private void replaceObjectInterfaces(ObjectInterfaceReplaceTarget target, Map typeMap) { + GraphQLObjectType objectType = target.objectType; + List resolvedInterfaces = new ArrayList<>(); + for (GraphQLNamedOutputType iface : objectType.getInterfaces()) { + if (iface instanceof GraphQLTypeReference) { + String typeName = ((GraphQLTypeReference) iface).getName(); + GraphQLNamedType resolved = typeMap.get(typeName); + if (resolved == null) { + throw new AssertException(String.format("Type '%s' not found in schema", typeName)); + } + if (!(resolved instanceof GraphQLInterfaceType)) { + throw new AssertException(String.format("Type '%s' is not an interface type", typeName)); + } + resolvedInterfaces.add((GraphQLInterfaceType) resolved); + } else { + resolvedInterfaces.add(iface); + } + } + objectType.replaceInterfaces(resolvedInterfaces); + } + + /** + * Resolve an output type, replacing any type references with actual types. + * Handles List and NonNull wrappers recursively. + */ + private GraphQLOutputType resolveOutputType(GraphQLOutputType type, Map typeMap) { + if (type instanceof GraphQLNonNull) { + GraphQLNonNull nonNull = (GraphQLNonNull) type; + GraphQLType wrappedType = nonNull.getWrappedType(); + if (wrappedType instanceof GraphQLOutputType) { + GraphQLOutputType resolvedWrapped = resolveOutputType((GraphQLOutputType) wrappedType, typeMap); + if (resolvedWrapped != wrappedType) { + nonNull.replaceType(resolvedWrapped); + } + } + return type; + } + if (type instanceof GraphQLList) { + GraphQLList list = (GraphQLList) type; + GraphQLType wrappedType = list.getWrappedType(); + if (wrappedType instanceof GraphQLOutputType) { + GraphQLOutputType resolvedWrapped = resolveOutputType((GraphQLOutputType) wrappedType, typeMap); + if (resolvedWrapped != wrappedType) { + list.replaceType(resolvedWrapped); + } + } + return type; + } + if (type instanceof GraphQLTypeReference) { + String typeName = ((GraphQLTypeReference) type).getName(); + GraphQLNamedType resolved = typeMap.get(typeName); + if (resolved == null) { + throw new AssertException(String.format("Type '%s' not found in schema", typeName)); + } + if (!(resolved instanceof GraphQLOutputType)) { + throw new AssertException(String.format("Type '%s' is not an output type", typeName)); + } + return (GraphQLOutputType) resolved; + } + // Already a concrete type, return as-is + return type; + } + /** * Resolve an input type, replacing any type references with actual types. * Handles List and NonNull wrappers recursively. diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index 6f7028f9c..2d3959bbd 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -1086,4 +1086,324 @@ class FastBuilderTest extends Specification { schema.getSchemaAppliedDirective("dir1") != null schema.getSchemaAppliedDirective("dir2") != null } + + // ==================== Phase 6: Object Types ==================== + + def "object type field with type reference resolves correctly"() { + given: "a custom object type" + def personType = newObject() + .name("Person") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + and: "a query type with field returning Person via type reference" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("person") + .type(typeRef("Person"))) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(personType) + .build() + + then: "field type is resolved" + def queryField = schema.queryType.getFieldDefinition("person") + queryField.getType() == personType + } + + def "object type field with NonNull wrapped type reference resolves correctly"() { + given: "a custom object type" + def itemType = newObject() + .name("Item") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "a query type with NonNull field" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("item") + .type(GraphQLNonNull.nonNull(typeRef("Item")))) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(itemType) + .build() + + then: "field type is resolved with NonNull wrapper" + def queryField = schema.queryType.getFieldDefinition("item") + def fieldType = queryField.getType() + fieldType instanceof GraphQLNonNull + ((GraphQLNonNull) fieldType).getWrappedType() == itemType + } + + def "object type field with List wrapped type reference resolves correctly"() { + given: "a custom object type" + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + and: "a query type with List field" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("users") + .type(GraphQLList.list(typeRef("User")))) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(userType) + .build() + + then: "field type is resolved with List wrapper" + def queryField = schema.queryType.getFieldDefinition("users") + def fieldType = queryField.getType() + fieldType instanceof GraphQLList + ((GraphQLList) fieldType).getWrappedType() == userType + } + + def "object type implementing interface with type reference resolves correctly"() { + given: "an interface type" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "an object type implementing interface via type reference" + def postType = newObject() + .name("Post") + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("title") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("post") + .type(postType)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(nodeInterface) + .additionalType(postType) + .build() + + then: "interface reference is resolved" + def resolvedPost = schema.getType("Post") as GraphQLObjectType + resolvedPost.getInterfaces().size() == 1 + resolvedPost.getInterfaces()[0] == nodeInterface + } + + def "interface to implementations map is built correctly"() { + given: "an interface type" + def entityInterface = GraphQLInterfaceType.newInterface() + .name("Entity") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "multiple object types implementing interface" + def userType = newObject() + .name("User") + .withInterface(entityInterface) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def productType = newObject() + .name("Product") + .withInterface(entityInterface) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("price") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("entity") + .type(entityInterface)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(entityInterface) + .additionalType(userType) + .additionalType(productType) + .build() + + then: "interface to implementations map is built" + def implementations = schema.getImplementations(entityInterface) + implementations.size() == 2 + implementations.any { it.name == "User" } + implementations.any { it.name == "Product" } + } + + def "object type field argument with type reference resolves correctly"() { + given: "an input type" + def filterInput = newInputObject() + .name("FilterInput") + .field(newInputObjectField() + .name("status") + .type(GraphQLString)) + .build() + + and: "an object type" + def resultType = newObject() + .name("Result") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "a query type with field having argument with type reference" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("search") + .argument(newArgument() + .name("filter") + .type(typeRef("FilterInput"))) + .type(resultType)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(filterInput) + .additionalType(resultType) + .build() + + then: "field argument type is resolved" + def searchField = schema.queryType.getFieldDefinition("search") + searchField.getArgument("filter").getType() == filterInput + } + + def "object type field with applied directive type reference resolves correctly"() { + given: "a custom scalar" + def metaScalar = newScalar() + .name("FieldMetadata") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive definition" + def directive = newDirective() + .name("fieldMeta") + .validLocation(Introspection.DirectiveLocation.FIELD_DEFINITION) + .argument(newArgument() + .name("info") + .type(metaScalar)) + .build() + + and: "an applied directive with type reference" + def appliedDirective = newAppliedDirective() + .name("fieldMeta") + .argument(newAppliedArgument() + .name("info") + .type(typeRef("FieldMetadata")) + .valueProgrammatic("metadata")) + .build() + + and: "a query type with field having applied directive" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString) + .withAppliedDirective(appliedDirective)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(metaScalar) + .additionalDirective(directive) + .build() + + then: "applied directive argument type on field is resolved" + def field = schema.queryType.getFieldDefinition("value") + def resolvedApplied = field.getAppliedDirective("fieldMeta") + resolvedApplied.getArgument("info").getType() == metaScalar + } + + def "object type missing field type reference throws error"() { + given: "a query type with missing type reference" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("missing") + .type(typeRef("NonExistent"))) + .build() + + when: "building" + new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .build() + + then: "error for missing type" + thrown(AssertException) + } + + def "object type with missing interface type reference throws error"() { + given: "an object type with missing interface reference" + def objectType = newObject() + .name("MyObject") + .withInterface(typeRef("NonExistentInterface")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("obj") + .type(objectType)) + .build() + + when: "building" + new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(objectType) + .build() + + then: "error for missing interface" + thrown(AssertException) + } } From ea5d3ccdc52f5f2a73d087297c3ed7cc28a3a644 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sat, 6 Dec 2025 21:46:09 +0000 Subject: [PATCH 08/25] Phase 7: Interface Types - FastBuilder implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extended ShallowTypeRefCollector to handle GraphQLInterfaceType: - Scans field types for type references - Scans field arguments for type references - Scans applied directives on fields - Scans extended interfaces for type references - Added handleInterfaceType() method - Added InterfaceInterfaceReplaceTarget wrapper class - Added replaceInterfaceInterfaces() for interface extension resolution - Updated FastBuilder.additionalType() to wire type resolvers from interfaces - Added comprehensive tests for interface type handling: - Basic interface type addition - Field type reference resolution - Interface extending interface via type reference - Type resolver wiring from interface - Field argument type reference resolution - Applied directive on interface fields - Error cases for missing extended interfaces 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/graphql/schema/GraphQLSchema.java | 9 + .../schema/ShallowTypeRefCollector.java | 59 +++- .../graphql/schema/FastBuilderTest.groovy | 285 ++++++++++++++++++ 3 files changed, 351 insertions(+), 2 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 00272d832..b71a5fcc8 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -1159,6 +1159,15 @@ public FastBuilder additionalType(GraphQLType type) { } } + // For interface types, wire type resolver if present + if (namedType instanceof GraphQLInterfaceType) { + GraphQLInterfaceType interfaceType = (GraphQLInterfaceType) namedType; + TypeResolver resolver = interfaceType.getTypeResolver(); + if (resolver != null) { + codeRegistryBuilder.typeResolverIfAbsent(interfaceType, resolver); + } + } + return this; } diff --git a/src/main/java/graphql/schema/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/ShallowTypeRefCollector.java index f8edb2498..01c7d17ca 100644 --- a/src/main/java/graphql/schema/ShallowTypeRefCollector.java +++ b/src/main/java/graphql/schema/ShallowTypeRefCollector.java @@ -33,12 +33,14 @@ public void handleTypeDef(GraphQLNamedType type) { handleInputObjectType((GraphQLInputObjectType) type); } else if (type instanceof GraphQLObjectType) { handleObjectType((GraphQLObjectType) type); + } else if (type instanceof GraphQLInterfaceType) { + handleInterfaceType((GraphQLInterfaceType) type); } // Scan applied directives on all directive container types if (type instanceof GraphQLDirectiveContainer) { scanAppliedDirectives(((GraphQLDirectiveContainer) type).getAppliedDirectives()); } - // Future phases will handle: GraphQLInterfaceType fields, GraphQLUnionType members + // Future phases will handle: GraphQLUnionType members } private void handleObjectType(GraphQLObjectType objectType) { @@ -69,6 +71,25 @@ private boolean hasInterfaceTypeReferences(List interfac return false; } + private void handleInterfaceType(GraphQLInterfaceType interfaceType) { + // Scan fields for type references (same as object types) + for (GraphQLFieldDefinition field : interfaceType.getFieldDefinitions()) { + if (containsTypeReference(field.getType())) { + replaceTargets.add(field); + } + // Scan field arguments + for (GraphQLArgument arg : field.getArguments()) { + scanArgumentType(arg); + } + // Scan applied directives on field + scanAppliedDirectives(field.getAppliedDirectives()); + } + // Interfaces can extend other interfaces + if (hasInterfaceTypeReferences(interfaceType.getInterfaces())) { + replaceTargets.add(new InterfaceInterfaceReplaceTarget(interfaceType)); + } + } + /** * Wrapper class to track object types that need interface replacement. */ @@ -80,6 +101,17 @@ static class ObjectInterfaceReplaceTarget { } } + /** + * Wrapper class to track interface types that need interface replacement. + */ + static class InterfaceInterfaceReplaceTarget { + final GraphQLInterfaceType interfaceType; + + InterfaceInterfaceReplaceTarget(GraphQLInterfaceType interfaceType) { + this.interfaceType = interfaceType; + } + } + private void handleInputObjectType(GraphQLInputObjectType inputType) { for (GraphQLInputObjectField field : inputType.getFieldDefinitions()) { if (containsTypeReference(field.getType())) { @@ -161,8 +193,10 @@ public void replaceTypes(Map typeMap) { replaceFieldType((GraphQLFieldDefinition) target, typeMap); } else if (target instanceof ObjectInterfaceReplaceTarget) { replaceObjectInterfaces((ObjectInterfaceReplaceTarget) target, typeMap); + } else if (target instanceof InterfaceInterfaceReplaceTarget) { + replaceInterfaceInterfaces((InterfaceInterfaceReplaceTarget) target, typeMap); } - // Future phases will handle: GraphQLInterfaceType interfaces, GraphQLUnionType members + // Future phases will handle: GraphQLUnionType members } } @@ -207,6 +241,27 @@ private void replaceObjectInterfaces(ObjectInterfaceReplaceTarget target, Map typeMap) { + GraphQLInterfaceType interfaceType = target.interfaceType; + List resolvedInterfaces = new ArrayList<>(); + for (GraphQLNamedOutputType iface : interfaceType.getInterfaces()) { + if (iface instanceof GraphQLTypeReference) { + String typeName = ((GraphQLTypeReference) iface).getName(); + GraphQLNamedType resolved = typeMap.get(typeName); + if (resolved == null) { + throw new AssertException(String.format("Type '%s' not found in schema", typeName)); + } + if (!(resolved instanceof GraphQLInterfaceType)) { + throw new AssertException(String.format("Type '%s' is not an interface type", typeName)); + } + resolvedInterfaces.add((GraphQLInterfaceType) resolved); + } else { + resolvedInterfaces.add(iface); + } + } + interfaceType.replaceInterfaces(resolvedInterfaces); + } + /** * Resolve an output type, replacing any type references with actual types. * Handles List and NonNull wrappers recursively. diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index 2d3959bbd..8c470e099 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -1406,4 +1406,289 @@ class FastBuilderTest extends Specification { then: "error for missing interface" thrown(AssertException) } + + // ==================== Phase 7: Interface Types ==================== + + def "interface type can be added to schema"() { + given: "an interface type" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .build() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(nodeInterface) + .build() + + then: "interface type is in schema" + def resolvedInterface = schema.getType("Node") + resolvedInterface instanceof GraphQLInterfaceType + (resolvedInterface as GraphQLInterfaceType).getFieldDefinition("id") != null + } + + def "interface type field with type reference resolves correctly"() { + given: "a custom object type" + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + and: "an interface type with field returning User via type reference" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("owner") + .type(typeRef("User"))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .build() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(nodeInterface) + .additionalType(userType) + .build() + + then: "interface field type is resolved" + def resolvedInterface = schema.getType("Node") as GraphQLInterfaceType + resolvedInterface.getFieldDefinition("owner").getType() == userType + } + + def "interface extending interface via type reference resolves correctly"() { + given: "a base interface" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "an interface extending Node via type reference" + def namedNodeInterface = GraphQLInterfaceType.newInterface() + .name("NamedNode") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .withInterface(typeRef("Node")) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .build() + + and: "code registry with type resolvers" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + .typeResolver("NamedNode", { env -> null }) + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(nodeInterface) + .additionalType(namedNodeInterface) + .build() + + then: "interface extension is resolved" + def resolvedNamedNode = schema.getType("NamedNode") as GraphQLInterfaceType + resolvedNamedNode.getInterfaces().size() == 1 + resolvedNamedNode.getInterfaces()[0] == nodeInterface + } + + def "interface type resolver from interface is wired to code registry"() { + given: "an interface type with inline type resolver" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .typeResolver({ env -> null }) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .build() + + and: "code registry (no type resolver)" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(nodeInterface) + .build() + + then: "type resolver is wired" + def resolvedInterface = schema.getType("Node") as GraphQLInterfaceType + schema.codeRegistry.getTypeResolver(resolvedInterface) != null + } + + def "interface field argument with type reference resolves correctly"() { + given: "an input type" + def filterInput = newInputObject() + .name("FilterInput") + .field(newInputObjectField() + .name("active") + .type(Scalars.GraphQLBoolean)) + .build() + + and: "an interface type with field having argument with type reference" + def searchableInterface = GraphQLInterfaceType.newInterface() + .name("Searchable") + .field(newFieldDefinition() + .name("search") + .argument(newArgument() + .name("filter") + .type(typeRef("FilterInput"))) + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("searchable") + .type(searchableInterface)) + .build() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Searchable", { env -> null }) + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(searchableInterface) + .additionalType(filterInput) + .build() + + then: "interface field argument type is resolved" + def resolvedInterface = schema.getType("Searchable") as GraphQLInterfaceType + resolvedInterface.getFieldDefinition("search").getArgument("filter").getType() == filterInput + } + + def "interface with missing extended interface type reference throws error"() { + given: "an interface with missing extended interface reference" + def childInterface = GraphQLInterfaceType.newInterface() + .name("ChildInterface") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .withInterface(typeRef("NonExistentInterface")) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("child") + .type(childInterface)) + .build() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("ChildInterface", { env -> null }) + + when: "building" + new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(childInterface) + .build() + + then: "error for missing interface" + thrown(AssertException) + } + + def "interface field with applied directive type reference resolves correctly"() { + given: "a custom scalar" + def metaScalar = newScalar() + .name("InterfaceMetadata") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive definition" + def directive = newDirective() + .name("interfaceMeta") + .validLocation(Introspection.DirectiveLocation.FIELD_DEFINITION) + .argument(newArgument() + .name("info") + .type(metaScalar)) + .build() + + and: "an applied directive with type reference" + def appliedDirective = newAppliedDirective() + .name("interfaceMeta") + .argument(newAppliedArgument() + .name("info") + .type(typeRef("InterfaceMetadata")) + .valueProgrammatic("metadata")) + .build() + + and: "an interface type with field having applied directive" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString) + .withAppliedDirective(appliedDirective)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .build() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(metaScalar) + .additionalType(nodeInterface) + .additionalDirective(directive) + .build() + + then: "applied directive argument type on interface field is resolved" + def resolvedInterface = schema.getType("Node") as GraphQLInterfaceType + def field = resolvedInterface.getFieldDefinition("id") + def resolvedApplied = field.getAppliedDirective("interfaceMeta") + resolvedApplied.getArgument("info").getType() == metaScalar + } } From 4472f7c2209c8b851c617b7dfa83178b84292d90 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sat, 6 Dec 2025 21:48:11 +0000 Subject: [PATCH 09/25] Phase 8: Union Types - FastBuilder implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extended ShallowTypeRefCollector to handle GraphQLUnionType: - Scans possible types for type references - Applied directives already handled by directive container scan - Added handleUnionType() method - Added UnionTypesReplaceTarget wrapper class - Added replaceUnionTypes() for union member type resolution - Updated FastBuilder.additionalType() to wire type resolvers from unions - Added comprehensive tests for union type handling: - Basic union type addition - Union member type reference resolution - Type resolver wiring from union - Error cases for missing member types - Applied directive on union types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/graphql/schema/GraphQLSchema.java | 9 + .../schema/ShallowTypeRefCollector.java | 49 +++- .../graphql/schema/FastBuilderTest.groovy | 231 ++++++++++++++++++ 3 files changed, 287 insertions(+), 2 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index b71a5fcc8..56dc46e41 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -1168,6 +1168,15 @@ public FastBuilder additionalType(GraphQLType type) { } } + // For union types, wire type resolver if present + if (namedType instanceof GraphQLUnionType) { + GraphQLUnionType unionType = (GraphQLUnionType) namedType; + TypeResolver resolver = unionType.getTypeResolver(); + if (resolver != null) { + codeRegistryBuilder.typeResolverIfAbsent(unionType, resolver); + } + } + return this; } diff --git a/src/main/java/graphql/schema/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/ShallowTypeRefCollector.java index 01c7d17ca..44a7f8712 100644 --- a/src/main/java/graphql/schema/ShallowTypeRefCollector.java +++ b/src/main/java/graphql/schema/ShallowTypeRefCollector.java @@ -40,7 +40,9 @@ public void handleTypeDef(GraphQLNamedType type) { if (type instanceof GraphQLDirectiveContainer) { scanAppliedDirectives(((GraphQLDirectiveContainer) type).getAppliedDirectives()); } - // Future phases will handle: GraphQLUnionType members + if (type instanceof GraphQLUnionType) { + handleUnionType((GraphQLUnionType) type); + } } private void handleObjectType(GraphQLObjectType objectType) { @@ -112,6 +114,27 @@ static class InterfaceInterfaceReplaceTarget { } } + private void handleUnionType(GraphQLUnionType unionType) { + // Check if any possible types are type references + for (GraphQLNamedOutputType possibleType : unionType.getTypes()) { + if (possibleType instanceof GraphQLTypeReference) { + replaceTargets.add(new UnionTypesReplaceTarget(unionType)); + break; + } + } + } + + /** + * Wrapper class to track union types that need member type replacement. + */ + static class UnionTypesReplaceTarget { + final GraphQLUnionType unionType; + + UnionTypesReplaceTarget(GraphQLUnionType unionType) { + this.unionType = unionType; + } + } + private void handleInputObjectType(GraphQLInputObjectType inputType) { for (GraphQLInputObjectField field : inputType.getFieldDefinitions()) { if (containsTypeReference(field.getType())) { @@ -195,8 +218,9 @@ public void replaceTypes(Map typeMap) { replaceObjectInterfaces((ObjectInterfaceReplaceTarget) target, typeMap); } else if (target instanceof InterfaceInterfaceReplaceTarget) { replaceInterfaceInterfaces((InterfaceInterfaceReplaceTarget) target, typeMap); + } else if (target instanceof UnionTypesReplaceTarget) { + replaceUnionTypes((UnionTypesReplaceTarget) target, typeMap); } - // Future phases will handle: GraphQLUnionType members } } @@ -262,6 +286,27 @@ private void replaceInterfaceInterfaces(InterfaceInterfaceReplaceTarget target, interfaceType.replaceInterfaces(resolvedInterfaces); } + private void replaceUnionTypes(UnionTypesReplaceTarget target, Map typeMap) { + GraphQLUnionType unionType = target.unionType; + List resolvedTypes = new ArrayList<>(); + for (GraphQLNamedOutputType possibleType : unionType.getTypes()) { + if (possibleType instanceof GraphQLTypeReference) { + String typeName = ((GraphQLTypeReference) possibleType).getName(); + GraphQLNamedType resolved = typeMap.get(typeName); + if (resolved == null) { + throw new AssertException(String.format("Type '%s' not found in schema", typeName)); + } + if (!(resolved instanceof GraphQLObjectType)) { + throw new AssertException(String.format("Type '%s' is not an object type (union members must be object types)", typeName)); + } + resolvedTypes.add((GraphQLObjectType) resolved); + } else { + resolvedTypes.add(possibleType); + } + } + unionType.replaceTypes(resolvedTypes); + } + /** * Resolve an output type, replacing any type references with actual types. * Handles List and NonNull wrappers recursively. diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index 8c470e099..afd3ab4c9 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -1691,4 +1691,235 @@ class FastBuilderTest extends Specification { def resolvedApplied = field.getAppliedDirective("interfaceMeta") resolvedApplied.getArgument("info").getType() == metaScalar } + + // ==================== Phase 8: Union Types ==================== + + def "union type can be added to schema"() { + given: "possible types for union" + def catType = newObject() + .name("Cat") + .field(newFieldDefinition() + .name("meow") + .type(GraphQLString)) + .build() + + def dogType = newObject() + .name("Dog") + .field(newFieldDefinition() + .name("bark") + .type(GraphQLString)) + .build() + + and: "a union with concrete types" + def petUnion = GraphQLUnionType.newUnionType() + .name("Pet") + .possibleType(catType) + .possibleType(dogType) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("pet") + .type(petUnion)) + .build() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Pet", { env -> null }) + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(catType) + .additionalType(dogType) + .additionalType(petUnion) + .build() + + then: "union type is in schema" + def resolvedUnion = schema.getType("Pet") + resolvedUnion instanceof GraphQLUnionType + (resolvedUnion as GraphQLUnionType).types.size() == 2 + } + + def "union type with type reference members resolves correctly"() { + given: "possible types for union" + def catType = newObject() + .name("Cat") + .field(newFieldDefinition() + .name("meow") + .type(GraphQLString)) + .build() + + def dogType = newObject() + .name("Dog") + .field(newFieldDefinition() + .name("bark") + .type(GraphQLString)) + .build() + + and: "a union with type references" + def petUnion = GraphQLUnionType.newUnionType() + .name("Pet") + .possibleType(typeRef("Cat")) + .possibleType(typeRef("Dog")) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("pet") + .type(petUnion)) + .build() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Pet", { env -> null }) + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(catType) + .additionalType(dogType) + .additionalType(petUnion) + .build() + + then: "union member types are resolved" + def resolvedPet = schema.getType("Pet") as GraphQLUnionType + resolvedPet.types.collect { it.name }.toSet() == ["Cat", "Dog"].toSet() + resolvedPet.types[0] in [catType, dogType] + resolvedPet.types[1] in [catType, dogType] + } + + def "union type resolver from union is wired to code registry"() { + given: "possible types for union" + def catType = newObject() + .name("Cat") + .field(newFieldDefinition() + .name("meow") + .type(GraphQLString)) + .build() + + and: "a union with inline type resolver" + def petUnion = GraphQLUnionType.newUnionType() + .name("Pet") + .possibleType(catType) + .typeResolver({ env -> null }) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("pet") + .type(petUnion)) + .build() + + and: "code registry (no type resolver)" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(catType) + .additionalType(petUnion) + .build() + + then: "type resolver is wired" + def resolvedUnion = schema.getType("Pet") as GraphQLUnionType + schema.codeRegistry.getTypeResolver(resolvedUnion) != null + } + + def "union with missing member type reference throws error"() { + given: "a union with missing type reference" + def petUnion = GraphQLUnionType.newUnionType() + .name("Pet") + .possibleType(typeRef("NonExistentType")) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("pet") + .type(petUnion)) + .build() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Pet", { env -> null }) + + when: "building" + new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(petUnion) + .build() + + then: "error for missing type" + thrown(AssertException) + } + + def "union with applied directive type reference resolves correctly"() { + given: "a custom scalar" + def metaScalar = newScalar() + .name("UnionMetadata") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive definition" + def directive = newDirective() + .name("unionMeta") + .validLocation(Introspection.DirectiveLocation.UNION) + .argument(newArgument() + .name("info") + .type(metaScalar)) + .build() + + and: "an applied directive with type reference" + def appliedDirective = newAppliedDirective() + .name("unionMeta") + .argument(newAppliedArgument() + .name("info") + .type(typeRef("UnionMetadata")) + .valueProgrammatic("metadata")) + .build() + + and: "possible type" + def catType = newObject() + .name("Cat") + .field(newFieldDefinition() + .name("meow") + .type(GraphQLString)) + .build() + + and: "a union with applied directive" + def petUnion = GraphQLUnionType.newUnionType() + .name("Pet") + .possibleType(catType) + .withAppliedDirective(appliedDirective) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("pet") + .type(petUnion)) + .build() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Pet", { env -> null }) + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(metaScalar) + .additionalType(catType) + .additionalType(petUnion) + .additionalDirective(directive) + .build() + + then: "applied directive argument type on union is resolved" + def resolvedUnion = schema.getType("Pet") as GraphQLUnionType + def resolvedApplied = resolvedUnion.getAppliedDirective("unionMeta") + resolvedApplied.getArgument("info").getType() == metaScalar + } } From e12c0742709177e45a34e1ddb008259da7d74693 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sat, 6 Dec 2025 21:54:01 +0000 Subject: [PATCH 10/25] Phase 9: Validation and Edge Cases - FastBuilder implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed additionalTypes not being set in FastBuilder path: - Added additionalTypes parameter to FastBuilder private constructor - Added buildAdditionalTypes() method to compute additionalTypes from typeMap - Validation now properly traverses all types in the schema - Added comprehensive tests for validation and edge cases: - withValidation(false) skips validation - withValidation(true) runs validation and catches errors - Circular type reference resolution - Deeply nested type reference resolution (NonNull + List + NonNull) - Complex schema with interfaces, unions, input types - Null handling for types/directives - Built-in directives are added automatically 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/graphql/schema/GraphQLSchema.java | 144 +-- .../schema/ShallowTypeRefCollector.java | 30 +- .../schema/impl/FindDetachedTypes.java | 171 +++ ...uilderComparisonAdditionalTypesTest.groovy | 713 +++++++++++ .../FastBuilderComparisonComplexTest.groovy | 851 ++++++++++++++ .../FastBuilderComparisonInterfaceTest.groovy | 741 ++++++++++++ .../FastBuilderComparisonMigratedTest.groovy | 397 +++++++ .../schema/FastBuilderComparisonTest.groovy | 195 +++ .../FastBuilderComparisonTypeRefTest.groovy | 1041 +++++++++++++++++ .../graphql/schema/FastBuilderTest.groovy | 447 ++++--- .../schema/impl/FindDetachedTypesTest.groovy | 1017 ++++++++++++++++ 11 files changed, 5493 insertions(+), 254 deletions(-) create mode 100644 src/main/java/graphql/schema/impl/FindDetachedTypes.java create mode 100644 src/test/groovy/graphql/schema/FastBuilderComparisonAdditionalTypesTest.groovy create mode 100644 src/test/groovy/graphql/schema/FastBuilderComparisonComplexTest.groovy create mode 100644 src/test/groovy/graphql/schema/FastBuilderComparisonInterfaceTest.groovy create mode 100644 src/test/groovy/graphql/schema/FastBuilderComparisonMigratedTest.groovy create mode 100644 src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy create mode 100644 src/test/groovy/graphql/schema/FastBuilderComparisonTypeRefTest.groovy create mode 100644 src/test/groovy/graphql/schema/impl/FindDetachedTypesTest.groovy diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 56dc46e41..538025e95 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -8,12 +8,14 @@ import graphql.AssertException; import graphql.Directives; import graphql.DirectivesUtil; +import graphql.ExperimentalApi; import graphql.Internal; import graphql.PublicApi; import graphql.collect.ImmutableKit; import graphql.introspection.Introspection; import graphql.language.SchemaDefinition; import graphql.language.SchemaExtensionDefinition; +import graphql.schema.impl.FindDetachedTypes; import graphql.schema.impl.GraphQLTypeCollectingVisitor; import graphql.schema.impl.SchemaUtil; import graphql.schema.validation.InvalidSchemaException; @@ -30,7 +32,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.TreeMap; import java.util.function.Consumer; import static graphql.Assert.assertNotNull; @@ -166,34 +167,48 @@ public GraphQLSchema(BuilderWithoutTypes builder) { } /** - * Constructor for FastBuilder - assembles the schema from precomputed fields. - * Performs NO traversals, NO reference replacement, and NO validation. + * Private constructor for FastBuilder that copies data from the builder + * and converts mutable collections to immutable ones. */ @Internal - private GraphQLSchema(FastBuilder fastBuilder, - ImmutableMap typeMap, - ImmutableList directives, - ImmutableList schemaDirectives, - ImmutableList schemaAppliedDirectives, - ImmutableMap> interfaceNameToObjectTypes, - ImmutableMap> interfaceNameToObjectTypeNames, - GraphQLCodeRegistry codeRegistry) { + private GraphQLSchema(FastBuilder fastBuilder) { + // Build immutable collections from FastBuilder's mutable state + ImmutableMap finalTypeMap = ImmutableMap.copyOf(fastBuilder.typeMap); + ImmutableList finalDirectives = ImmutableList.copyOf(fastBuilder.directiveMap.values()); + + // Get interface-to-object-type-names map from collector (already sorted) + ImmutableMap> finalInterfaceNameMap = + fastBuilder.shallowTypeRefCollector.getInterfaceNameToObjectTypeNames(); + + // Build interface-to-object-types map by looking up types in typeMap + ImmutableMap.Builder> interfaceMapBuilder = ImmutableMap.builder(); + for (Map.Entry> entry : finalInterfaceNameMap.entrySet()) { + ImmutableList objectTypes = map(entry.getValue(), + name -> (GraphQLObjectType) finalTypeMap.get(name)); + interfaceMapBuilder.put(entry.getKey(), objectTypes); + } + ImmutableMap> finalInterfaceMap = interfaceMapBuilder.build(); + + // Initialize all fields this.queryType = fastBuilder.queryType; this.mutationType = fastBuilder.mutationType; this.subscriptionType = fastBuilder.subscriptionType; this.introspectionSchemaType = fastBuilder.introspectionSchemaType; - this.additionalTypes = ImmutableSet.of(); // Not used in FastBuilder path + this.additionalTypes = ImmutableSet.copyOf(FindDetachedTypes.findDetachedTypes( + finalTypeMap, fastBuilder.queryType, fastBuilder.mutationType, fastBuilder.subscriptionType, finalDirectives)); this.introspectionSchemaField = Introspection.buildSchemaField(fastBuilder.introspectionSchemaType); this.introspectionTypeField = Introspection.buildTypeField(fastBuilder.introspectionSchemaType); - this.directiveDefinitionsHolder = new DirectivesUtil.DirectivesHolder(directives, emptyList()); - this.schemaAppliedDirectivesHolder = new DirectivesUtil.DirectivesHolder(schemaDirectives, schemaAppliedDirectives); + this.directiveDefinitionsHolder = new DirectivesUtil.DirectivesHolder(finalDirectives, emptyList()); + this.schemaAppliedDirectivesHolder = new DirectivesUtil.DirectivesHolder( + ImmutableList.copyOf(fastBuilder.schemaDirectives), + ImmutableList.copyOf(fastBuilder.schemaAppliedDirectives)); this.definition = fastBuilder.definition; this.extensionDefinitions = nonNullCopyOf(fastBuilder.extensionDefinitions); this.description = fastBuilder.description; - this.codeRegistry = codeRegistry; - this.typeMap = typeMap; - this.interfaceNameToObjectTypes = interfaceNameToObjectTypes; - this.interfaceNameToObjectTypeNames = interfaceNameToObjectTypeNames; + this.codeRegistry = fastBuilder.codeRegistryBuilder.build(); + this.typeMap = finalTypeMap; + this.interfaceNameToObjectTypes = finalInterfaceMap; + this.interfaceNameToObjectTypeNames = finalInterfaceNameMap; } private static GraphQLDirective[] schemaDirectivesArray(GraphQLSchema existingSchema) { @@ -1042,26 +1057,24 @@ private GraphQLSchema validateSchema(GraphQLSchema graphQLSchema) { /** * A high-performance schema builder that avoids all full-schema traversals performed by - * {@link GraphQLSchema.Builder#build()}. It is intended for constructing schemas, especially - * large or deeply nested schemas. - *

- * FastBuilder is an "expert mode" builder with stricter assumptions: + * {@link GraphQLSchema.Builder#build()}. This builder is both significantly faster and + * allocates significantly less memory than the standard GraphQLSchema.Builder - however + * it's subject to limitations listed below. It is intended for constructing large + * schemas, especially deeply nested ones. + * + *

Use FastBuilder when: *

    - *
  • No clearing/resetting of types or directives
  • - *
  • All named types must be explicitly provided via {@link #additionalType(GraphQLType)}
  • - *
  • Incremental work only: when a type or directive is added, only that node is examined
  • - *
  • No schema traversals except shallow scans for local type references
  • - *
  • Validation is optional, off by default
  • + *
  • Building large schemas (500+ types) where construction time and memory are measurable
  • + *
  • All types are known without traversal and can be added explicitly with addAdditionalType(s)
  • + *
  • There's no need to clear/reset the builder state midstream.
  • + *
  • The code registry builder is complete and available when FastBuilder is constructed
  • *
- *

- * Performance is gained by eliminating: - *

    - *
  1. Full traversal for code-registry wiring
  2. - *
  3. Full traversal for type-reference replacement
  4. - *
  5. Full traversal for validation (if disabled)
  6. - *
+ * FastBuilder also can optionally skip schema validation, which can save time and + * memory for large schemas that have been previously validated (eg, in build tool chains). + * + * @see GraphQLSchema.Builder for standard schema construction */ - @PublicApi + @ExperimentalApi @NullMarked public static final class FastBuilder { // Fields consumed by the private constructor @@ -1073,7 +1086,6 @@ public static final class FastBuilder { private final Map directiveMap = new LinkedHashMap<>(); private final List schemaDirectives = new ArrayList<>(); private final List schemaAppliedDirectives = new ArrayList<>(); - private final Map> interfacesToImplementations = new LinkedHashMap<>(); private @Nullable String description; private @Nullable SchemaDefinition definition; private @Nullable List extensionDefinitions; @@ -1147,18 +1159,9 @@ public FastBuilder additionalType(GraphQLType type) { // Insert into typeMap typeMap.put(name, namedType); - // Shallow scan via ShallowTypeRefCollector + // Shallow scan via ShallowTypeRefCollector (also tracks interface implementations) shallowTypeRefCollector.handleTypeDef(namedType); - // For object types, update interface→implementations map - if (namedType instanceof GraphQLObjectType) { - GraphQLObjectType objectType = (GraphQLObjectType) namedType; - for (GraphQLNamedOutputType iface : objectType.getInterfaces()) { - String interfaceName = iface.getName(); - interfacesToImplementations.computeIfAbsent(interfaceName, k -> new ArrayList<>()).add(objectType); - } - } - // For interface types, wire type resolver if present if (namedType instanceof GraphQLInterfaceType) { GraphQLInterfaceType interfaceType = (GraphQLInterfaceType) namedType; @@ -1352,21 +1355,10 @@ public GraphQLSchema build() { // Step 2: Add built-in directives if missing addBuiltInDirectivesIfMissing(); - // Step 3: Build final immutable objects - ImmutableMap finalTypeMap = buildSortedImmutableTypeMap(); - ImmutableList finalDirectives = buildImmutableDirectives(); - ImmutableList finalSchemaDirectives = ImmutableList.copyOf(schemaDirectives); - ImmutableList finalSchemaAppliedDirectives = ImmutableList.copyOf(schemaAppliedDirectives); - ImmutableMap> finalInterfaceMap = buildImmutableInterfaceMap(); - ImmutableMap> finalInterfaceNameMap = buildInterfaceNameMap(finalInterfaceMap); - GraphQLCodeRegistry finalCodeRegistry = codeRegistryBuilder.build(); - - // Step 4: Create schema via private constructor - GraphQLSchema schema = new GraphQLSchema(this, finalTypeMap, finalDirectives, - finalSchemaDirectives, finalSchemaAppliedDirectives, - finalInterfaceMap, finalInterfaceNameMap, finalCodeRegistry); - - // Step 5: Optional validation + // Step 3: Create schema via private constructor + GraphQLSchema schema = new GraphQLSchema(this); + + // Step 4: Optional validation if (validationEnabled) { Collection errors = new SchemaValidator().validateSchema(schema); if (!errors.isEmpty()) { @@ -1374,7 +1366,6 @@ public GraphQLSchema build() { } } - // Step 6: Return return schema; } @@ -1392,34 +1383,5 @@ private void addDirectiveIfMissing(GraphQLDirective directive) { directiveMap.put(directive.getName(), directive); } } - - private ImmutableMap buildSortedImmutableTypeMap() { - TreeMap sorted = new TreeMap<>(typeMap); - return ImmutableMap.copyOf(sorted); - } - - private ImmutableList buildImmutableDirectives() { - return ImmutableList.copyOf(directiveMap.values()); - } - - private ImmutableMap> buildImmutableInterfaceMap() { - ImmutableMap.Builder> builder = ImmutableMap.builder(); - for (Map.Entry> entry : interfacesToImplementations.entrySet()) { - ImmutableList sortedObjectTypes = ImmutableList.copyOf( - sortTypes(byNameAsc(), entry.getValue())); - builder.put(entry.getKey(), sortedObjectTypes); - } - return builder.build(); - } - - private ImmutableMap> buildInterfaceNameMap( - ImmutableMap> interfaceMap) { - ImmutableMap.Builder> builder = ImmutableMap.builder(); - for (Map.Entry> entry : interfaceMap.entrySet()) { - ImmutableList objectTypeNames = map(entry.getValue(), GraphQLObjectType::getName); - builder.put(entry.getKey(), objectTypeNames); - } - return builder.build(); - } } } diff --git a/src/main/java/graphql/schema/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/ShallowTypeRefCollector.java index 44a7f8712..140187f18 100644 --- a/src/main/java/graphql/schema/ShallowTypeRefCollector.java +++ b/src/main/java/graphql/schema/ShallowTypeRefCollector.java @@ -1,16 +1,21 @@ package graphql.schema; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import graphql.AssertException; import graphql.Internal; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.TreeSet; /** * Collects type-refs found in type- and directive-definitions for later replacement with actual types. * This class performs shallow scans (no recursive traversal from one type-def to another) and * collects replacement targets that need their type references resolved. + * Also tracks interface-to-implementation relationships. */ @Internal public class ShallowTypeRefCollector { @@ -22,6 +27,9 @@ public class ShallowTypeRefCollector { // GraphQLAppliedDirectiveArgument private final List replaceTargets = new ArrayList<>(); + // Track interface implementations: interface name -> sorted set of implementing object type names + private final Map> interfaceToObjectTypeNames = new LinkedHashMap<>(); + /** * Scan a type definition for type references. * Called on GraphQL{Object|Input|Scalar|Union|etc}Type - NOT on wrappers or type-refs. @@ -58,7 +66,13 @@ private void handleObjectType(GraphQLObjectType objectType) { // Scan applied directives on field scanAppliedDirectives(field.getAppliedDirectives()); } - // Scan interfaces for type references + // Track interface implementations and scan for type references + for (GraphQLNamedOutputType iface : objectType.getInterfaces()) { + String interfaceName = iface.getName(); + interfaceToObjectTypeNames + .computeIfAbsent(interfaceName, k -> new TreeSet<>()) + .add(objectType.getName()); + } if (hasInterfaceTypeReferences(objectType.getInterfaces())) { replaceTargets.add(new ObjectInterfaceReplaceTarget(objectType)); } @@ -390,4 +404,18 @@ private GraphQLInputType resolveInputType(GraphQLInputType type, Map> getInterfaceNameToObjectTypeNames() { + ImmutableMap.Builder> builder = ImmutableMap.builder(); + for (Map.Entry> entry : interfaceToObjectTypeNames.entrySet()) { + builder.put(entry.getKey(), ImmutableList.copyOf(entry.getValue())); + } + return builder.build(); + } } diff --git a/src/main/java/graphql/schema/impl/FindDetachedTypes.java b/src/main/java/graphql/schema/impl/FindDetachedTypes.java new file mode 100644 index 000000000..a42141e90 --- /dev/null +++ b/src/main/java/graphql/schema/impl/FindDetachedTypes.java @@ -0,0 +1,171 @@ +package graphql.schema.impl; + +import graphql.Internal; +import graphql.schema.GraphQLArgument; +import graphql.schema.GraphQLDirective; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLInputObjectField; +import graphql.schema.GraphQLInputObjectType; +import graphql.schema.GraphQLInterfaceType; +import graphql.schema.GraphQLList; +import graphql.schema.GraphQLNamedType; +import graphql.schema.GraphQLNonNull; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLOutputType; +import graphql.schema.GraphQLType; +import graphql.schema.GraphQLUnionType; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Finds detached types in a schema by performing a DFS traversal from root types + * and directive definitions to find all reachable (attached) types, then computing the complement. + */ +@Internal +public class FindDetachedTypes { + + /** + * Computes the set of detached types - types that exist in the typeMap but are not + * reachable from the root types (Query, Mutation, Subscription) or directive definitions. + * + * @param typeMap all types in the schema + * @param queryType the query root type (required) + * @param mutationType the mutation root type (may be null) + * @param subscriptionType the subscription root type (may be null) + * @param directives directive definitions in the schema + * @return set of types that are not reachable from root types or directives + */ + public static Set findDetachedTypes(Map typeMap, + GraphQLObjectType queryType, + GraphQLObjectType mutationType, + GraphQLObjectType subscriptionType, + Collection directives) { + int typeCount = typeMap.size(); + Set attachedTypeNames = new HashSet<>(typeCount); + + // DFS from each root type to find all reachable types + visitType(queryType, attachedTypeNames); + if (mutationType != null) { + visitType(mutationType, attachedTypeNames); + } + if (subscriptionType != null) { + visitType(subscriptionType, attachedTypeNames); + } + + // Also visit types reachable from directive argument definitions + for (GraphQLDirective directive : directives) { + visitDirective(directive, attachedTypeNames); + } + + // Detached types = all types minus attached types + // Use Math.max to ensure capacity is never negative (can happen if attached types include + // types not in typeMap, like built-in scalars) + int detachedCapacity = Math.max(0, typeCount - attachedTypeNames.size()); + Set detachedTypes = new HashSet<>(detachedCapacity); + for (GraphQLNamedType type : typeMap.values()) { + if (!attachedTypeNames.contains(type.getName())) { + detachedTypes.add(type); + } + } + + return detachedTypes; + } + + private static void visitDirective(GraphQLDirective directive, Set visited) { + // Visit argument types in the directive definition + for (GraphQLArgument arg : directive.getArguments()) { + visitType(arg.getType(), visited); + } + } + + private static void visitType(GraphQLType type, Set visited) { + // Unwrap modifiers (NonNull, List) + GraphQLType unwrapped = unwrapType(type); + + if (!(unwrapped instanceof GraphQLNamedType)) { + return; + } + + GraphQLNamedType namedType = (GraphQLNamedType) unwrapped; + String typeName = namedType.getName(); + + // Skip if already visited + if (visited.contains(typeName)) { + return; + } + visited.add(typeName); + + // Visit fields and their types recursively + if (namedType instanceof GraphQLObjectType) { + visitObjectType((GraphQLObjectType) namedType, visited); + } else if (namedType instanceof GraphQLInterfaceType) { + visitInterfaceType((GraphQLInterfaceType) namedType, visited); + } else if (namedType instanceof GraphQLUnionType) { + visitUnionType((GraphQLUnionType) namedType, visited); + } else if (namedType instanceof GraphQLInputObjectType) { + visitInputObjectType((GraphQLInputObjectType) namedType, visited); + } + // Scalars and Enums have no further types to visit + } + + private static void visitObjectType(GraphQLObjectType objectType, Set visited) { + // Visit interfaces this object implements + for (GraphQLOutputType iface : objectType.getInterfaces()) { + visitType(iface, visited); + } + + // Visit field types + for (GraphQLFieldDefinition field : objectType.getFieldDefinitions()) { + visitType(field.getType(), visited); + // Visit argument types + for (GraphQLArgument arg : field.getArguments()) { + visitType(arg.getType(), visited); + } + } + } + + private static void visitInterfaceType(GraphQLInterfaceType interfaceType, Set visited) { + // Visit interfaces this interface extends + for (GraphQLOutputType iface : interfaceType.getInterfaces()) { + visitType(iface, visited); + } + + // Visit field types + for (GraphQLFieldDefinition field : interfaceType.getFieldDefinitions()) { + visitType(field.getType(), visited); + // Visit argument types + for (GraphQLArgument arg : field.getArguments()) { + visitType(arg.getType(), visited); + } + } + } + + private static void visitUnionType(GraphQLUnionType unionType, Set visited) { + // Visit all possible types in the union + for (GraphQLOutputType possibleType : unionType.getTypes()) { + visitType(possibleType, visited); + } + } + + private static void visitInputObjectType(GraphQLInputObjectType inputObjectType, Set visited) { + // Visit field types + for (GraphQLInputObjectField field : inputObjectType.getFieldDefinitions()) { + visitType(field.getType(), visited); + } + } + + private static GraphQLType unwrapType(GraphQLType type) { + GraphQLType current = type; + while (current instanceof GraphQLNonNull || current instanceof GraphQLList) { + if (current instanceof GraphQLNonNull) { + current = ((GraphQLNonNull) current).getWrappedType(); + } else { + current = ((GraphQLList) current).getWrappedType(); + } + } + return current; + } +} diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonAdditionalTypesTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonAdditionalTypesTest.groovy new file mode 100644 index 000000000..d475fd71b --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonAdditionalTypesTest.groovy @@ -0,0 +1,713 @@ +package graphql.schema + +import spock.lang.Specification + +import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLArgument.newArgument +import static graphql.schema.GraphQLDirective.newDirective +import static graphql.schema.GraphQLEnumType.newEnum +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLInputObjectField.newInputObjectField +import static graphql.schema.GraphQLInputObjectType.newInputObject +import static graphql.schema.GraphQLObjectType.newObject +import static graphql.schema.GraphQLScalarType.newScalar +import static graphql.introspection.Introspection.DirectiveLocation + +/** + * Comparison tests for AdditionalTypes (detached types) between FastBuilder and standard Builder. + * + * Detached types are types that exist in the schema but are not reachable from root types + * (Query, Mutation, Subscription) or directive arguments. These tests verify that FastBuilder's + * FindDetachedTypes implementation produces the same additionalTypes set as the standard builder. + */ +class FastBuilderComparisonAdditionalTypesTest extends FastBuilderComparisonTest { + + def "schema with detached type not reachable from roots has matching additionalTypes"() { + given: "SDL with a detached type" + def sdl = """ + type Query { + value: String + } + + # DetachedType is not referenced anywhere - it's detached + type DetachedType { + field: String + } + """ + + and: "programmatically created types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def detachedType = newObject() + .name("DetachedType") + .field(newFieldDefinition() + .name("field") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [detachedType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "both have DetachedType in additionalTypes" + standardSchema.additionalTypes*.name.toSet().contains("DetachedType") + fastSchema.additionalTypes*.name.toSet().contains("DetachedType") + } + + def "schema with type reachable from Query does not include it in additionalTypes"() { + given: "SDL with type reachable from Query" + def sdl = """ + type Query { + user: User + } + + type User { + name: String + } + """ + + and: "programmatically created types" + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("user") + .type(userType)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [userType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "User is NOT in additionalTypes for either schema (it's reachable from Query)" + !standardSchema.additionalTypes*.name.toSet().contains("User") + !fastSchema.additionalTypes*.name.toSet().contains("User") + } + + def "schema with type reachable from Mutation does not include it in additionalTypes"() { + given: "SDL with type reachable from Mutation" + def sdl = """ + type Query { + value: String + } + + type Mutation { + createUser(input: CreateUserInput): User + } + + input CreateUserInput { + name: String + } + + type User { + name: String + } + """ + + and: "programmatically created types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def inputType = newInputObject() + .name("CreateUserInput") + .field(newInputObjectField() + .name("name") + .type(GraphQLString)) + .build() + + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def mutationType = newObject() + .name("Mutation") + .field(newFieldDefinition() + .name("createUser") + .argument(newArgument() + .name("input") + .type(inputType)) + .type(userType)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, mutationType, null, [userType, inputType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "User and CreateUserInput are NOT in additionalTypes (reachable from Mutation)" + !standardSchema.additionalTypes*.name.toSet().contains("User") + !fastSchema.additionalTypes*.name.toSet().contains("User") + !standardSchema.additionalTypes*.name.toSet().contains("CreateUserInput") + !fastSchema.additionalTypes*.name.toSet().contains("CreateUserInput") + } + + def "schema with type reachable from Subscription does not include it in additionalTypes"() { + given: "SDL with type reachable from Subscription" + def sdl = """ + type Query { + value: String + } + + type Subscription { + userUpdated: UserUpdate + } + + type UserUpdate { + user: User + } + + type User { + name: String + } + """ + + and: "programmatically created types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def userUpdateType = newObject() + .name("UserUpdate") + .field(newFieldDefinition() + .name("user") + .type(userType)) + .build() + + def subscriptionType = newObject() + .name("Subscription") + .field(newFieldDefinition() + .name("userUpdated") + .type(userUpdateType)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, subscriptionType, [userType, userUpdateType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "UserUpdate and User are NOT in additionalTypes (reachable from Subscription)" + !standardSchema.additionalTypes*.name.toSet().contains("UserUpdate") + !fastSchema.additionalTypes*.name.toSet().contains("UserUpdate") + !standardSchema.additionalTypes*.name.toSet().contains("User") + !fastSchema.additionalTypes*.name.toSet().contains("User") + } + + def "schema with type reachable only via directive argument does not include it in additionalTypes"() { + given: "SDL with type only used in directive argument" + def sdl = """ + type Query { + value: String + } + + # ConfigValue is only used in the directive argument + scalar ConfigValue + + directive @config(value: ConfigValue) on FIELD + """ + + and: "programmatically created types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def configScalar = newScalar() + .name("ConfigValue") + .coercing(GraphQLString.getCoercing()) + .build() + + def directive = newDirective() + .name("config") + .validLocation(DirectiveLocation.FIELD) + .argument(newArgument() + .name("value") + .type(configScalar)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [configScalar], + [directive] + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "ConfigValue is NOT in additionalTypes (reachable via directive argument)" + !standardSchema.additionalTypes*.name.toSet().contains("ConfigValue") + !fastSchema.additionalTypes*.name.toSet().contains("ConfigValue") + } + + def "complex schema with multiple detached types has matching additionalTypes"() { + given: "SDL with multiple detached types" + def sdl = """ + type Query { + user: User + } + + type User { + name: String + } + + # These types are all detached (not reachable from Query) + type DetachedOne { + field: String + } + + type DetachedTwo { + field: String + } + + enum DetachedEnum { + VALUE_A + VALUE_B + } + + input DetachedInput { + field: String + } + """ + + and: "programmatically created types" + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("user") + .type(userType)) + .build() + + def detachedOne = newObject() + .name("DetachedOne") + .field(newFieldDefinition() + .name("field") + .type(GraphQLString)) + .build() + + def detachedTwo = newObject() + .name("DetachedTwo") + .field(newFieldDefinition() + .name("field") + .type(GraphQLString)) + .build() + + def detachedEnum = newEnum() + .name("DetachedEnum") + .value("VALUE_A") + .value("VALUE_B") + .build() + + def detachedInput = newInputObject() + .name("DetachedInput") + .field(newInputObjectField() + .name("field") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [userType, detachedOne, detachedTwo, detachedEnum, detachedInput] + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "all detached types are in additionalTypes" + def standardAdditional = standardSchema.additionalTypes*.name.toSet() + def fastAdditional = fastSchema.additionalTypes*.name.toSet() + + standardAdditional.contains("DetachedOne") + standardAdditional.contains("DetachedTwo") + standardAdditional.contains("DetachedEnum") + standardAdditional.contains("DetachedInput") + + fastAdditional.contains("DetachedOne") + fastAdditional.contains("DetachedTwo") + fastAdditional.contains("DetachedEnum") + fastAdditional.contains("DetachedInput") + + and: "User is NOT in additionalTypes (it's reachable)" + !standardAdditional.contains("User") + !fastAdditional.contains("User") + } + + def "schema with no detached types has empty additionalTypes in both builders"() { + given: "SDL with all types reachable from Query" + def sdl = """ + type Query { + user: User + post: Post + } + + type User { + name: String + posts: [Post] + } + + type Post { + title: String + author: User + } + """ + + and: "programmatically created types" + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def postType = newObject() + .name("Post") + .field(newFieldDefinition() + .name("title") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("author") + .type(userType)) + .build() + + // Add posts field to userType after postType is created + userType = userType.transform({ builder -> + builder.field(newFieldDefinition() + .name("posts") + .type(GraphQLList.list(postType))) + }) + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("user") + .type(userType)) + .field(newFieldDefinition() + .name("post") + .type(postType)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [userType, postType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "both have empty additionalTypes (all types are reachable)" + standardSchema.additionalTypes.isEmpty() + fastSchema.additionalTypes.isEmpty() + } + + def "schema with detached type transitively referencing other types includes all in additionalTypes"() { + given: "SDL with detached types that reference each other" + def sdl = """ + type Query { + value: String + } + + # DetachedOne is not reachable from Query + type DetachedOne { + nested: DetachedTwo + } + + # DetachedTwo is referenced by DetachedOne, but neither is reachable + type DetachedTwo { + field: String + } + """ + + and: "programmatically created types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def detachedTwo = newObject() + .name("DetachedTwo") + .field(newFieldDefinition() + .name("field") + .type(GraphQLString)) + .build() + + def detachedOne = newObject() + .name("DetachedOne") + .field(newFieldDefinition() + .name("nested") + .type(detachedTwo)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [detachedOne, detachedTwo]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "both DetachedOne and DetachedTwo are in additionalTypes" + def standardAdditional = standardSchema.additionalTypes*.name.toSet() + def fastAdditional = fastSchema.additionalTypes*.name.toSet() + + standardAdditional.contains("DetachedOne") + standardAdditional.contains("DetachedTwo") + fastAdditional.contains("DetachedOne") + fastAdditional.contains("DetachedTwo") + } + + def "schema with type implementing interface has correct additionalTypes"() { + given: "SDL with interface and implementation" + def sdl = """ + type Query { + node: Node + } + + interface Node { + id: String + } + + # User implements Node - it's in additionalTypes because interface implementations + # are not automatically traversed from the interface type itself + type User implements Node { + id: String + name: String + } + """ + + and: "programmatically created types" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def userType = newObject() + .name("User") + .withInterface(nodeInterface) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> userType }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [nodeInterface, userType], [], codeRegistry) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "Node is NOT in additionalTypes (reachable from Query)" + !standardSchema.additionalTypes*.name.toSet().contains("Node") + !fastSchema.additionalTypes*.name.toSet().contains("Node") + + and: "User IS in additionalTypes (interface implementations are not auto-traversed)" + standardSchema.additionalTypes*.name.toSet().contains("User") + fastSchema.additionalTypes*.name.toSet().contains("User") + } + + def "schema with type used in union is not in additionalTypes"() { + given: "SDL with union type" + def sdl = """ + type Query { + searchResult: SearchResult + } + + union SearchResult = User | Post + + type User { + name: String + } + + type Post { + title: String + } + """ + + and: "programmatically created types" + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def postType = newObject() + .name("Post") + .field(newFieldDefinition() + .name("title") + .type(GraphQLString)) + .build() + + def searchResultUnion = GraphQLUnionType.newUnionType() + .name("SearchResult") + .possibleType(userType) + .possibleType(postType) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("searchResult") + .type(searchResultUnion)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("SearchResult", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [userType, postType, searchResultUnion], + [], + codeRegistry + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "SearchResult, User, and Post are NOT in additionalTypes (all reachable from Query)" + def standardAdditional = standardSchema.additionalTypes*.name.toSet() + def fastAdditional = fastSchema.additionalTypes*.name.toSet() + + !standardAdditional.contains("SearchResult") + !standardAdditional.contains("User") + !standardAdditional.contains("Post") + + !fastAdditional.contains("SearchResult") + !fastAdditional.contains("User") + !fastAdditional.contains("Post") + } + + def "schema with type used in input object field is not in additionalTypes when input is reachable"() { + given: "SDL with nested input types" + def sdl = """ + type Query { + createUser(input: UserInput): String + } + + input UserInput { + name: String + address: AddressInput + } + + input AddressInput { + street: String + city: String + } + """ + + and: "programmatically created types" + def addressInput = newInputObject() + .name("AddressInput") + .field(newInputObjectField() + .name("street") + .type(GraphQLString)) + .field(newInputObjectField() + .name("city") + .type(GraphQLString)) + .build() + + def userInput = newInputObject() + .name("UserInput") + .field(newInputObjectField() + .name("name") + .type(GraphQLString)) + .field(newInputObjectField() + .name("address") + .type(addressInput)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("createUser") + .argument(newArgument() + .name("input") + .type(userInput)) + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [userInput, addressInput]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "UserInput and AddressInput are NOT in additionalTypes (both reachable from Query)" + !standardSchema.additionalTypes*.name.toSet().contains("UserInput") + !fastSchema.additionalTypes*.name.toSet().contains("UserInput") + !standardSchema.additionalTypes*.name.toSet().contains("AddressInput") + !fastSchema.additionalTypes*.name.toSet().contains("AddressInput") + } +} diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonComplexTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonComplexTest.groovy new file mode 100644 index 000000000..54164f054 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonComplexTest.groovy @@ -0,0 +1,851 @@ +package graphql.schema + +import graphql.Scalars +import spock.lang.Specification + +import static graphql.Scalars.GraphQLBoolean +import static graphql.Scalars.GraphQLInt +import static graphql.Scalars.GraphQLString +import static graphql.introspection.Introspection.DirectiveLocation +import static graphql.schema.GraphQLArgument.newArgument +import static graphql.schema.GraphQLDirective.newDirective +import static graphql.schema.GraphQLEnumType.newEnum +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLInputObjectField.newInputObjectField +import static graphql.schema.GraphQLInputObjectType.newInputObject +import static graphql.schema.GraphQLInterfaceType.newInterface +import static graphql.schema.GraphQLObjectType.newObject +import static graphql.schema.GraphQLScalarType.newScalar +import static graphql.schema.GraphQLTypeReference.typeRef +import static graphql.schema.GraphQLUnionType.newUnionType + +/** + * Comparison tests for Complex Schemas and Directives. + * + * Tests that FastBuilder produces schemas equivalent to SDL-parsed schemas + * for complex type compositions and directive handling. + */ +class FastBuilderComparisonComplexTest extends FastBuilderComparisonTest { + + // ==================== Complex Schemas ==================== + + def "schema with all GraphQL type kinds matches between FastBuilder and standard builder"() { + given: "SDL with all type kinds" + def sdl = """ + type Query { + user: User + search(input: SearchInput): SearchResult + status: Status + timestamp: DateTime + } + + type Mutation { + updateUser(input: UserInput): User + } + + type Subscription { + userUpdated: User + } + + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + name: String! + posts: [Post!]! + } + + type Post implements Node { + id: ID! + title: String! + author: User! + } + + union SearchResult = User | Post + + enum Status { + ACTIVE + INACTIVE + PENDING + } + + scalar DateTime + + input UserInput { + name: String! + status: Status + } + + input SearchInput { + query: String! + limit: Int + } + """ + + and: "programmatically created types" + // Custom scalar + def dateTimeScalar = newScalar() + .name("DateTime") + .coercing(GraphQLString.getCoercing()) + .build() + + // Enum + def statusEnum = newEnum() + .name("Status") + .value("ACTIVE") + .value("INACTIVE") + .value("PENDING") + .build() + + // Interface + def nodeInterface = newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLNonNull.nonNull(Scalars.GraphQLID))) + .build() + + // Input types + def userInput = newInputObject() + .name("UserInput") + .field(newInputObjectField() + .name("name") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newInputObjectField() + .name("status") + .type(typeRef("Status"))) + .build() + + def searchInput = newInputObject() + .name("SearchInput") + .field(newInputObjectField() + .name("query") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newInputObjectField() + .name("limit") + .type(GraphQLInt)) + .build() + + // Object types + def userType = newObject() + .name("User") + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLNonNull.nonNull(Scalars.GraphQLID))) + .field(newFieldDefinition() + .name("name") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newFieldDefinition() + .name("posts") + .type(GraphQLNonNull.nonNull(GraphQLList.list(GraphQLNonNull.nonNull(typeRef("Post")))))) + .build() + + def postType = newObject() + .name("Post") + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLNonNull.nonNull(Scalars.GraphQLID))) + .field(newFieldDefinition() + .name("title") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newFieldDefinition() + .name("author") + .type(GraphQLNonNull.nonNull(typeRef("User")))) + .build() + + // Union + def searchResultUnion = newUnionType() + .name("SearchResult") + .possibleType(typeRef("User")) + .possibleType(typeRef("Post")) + .build() + + // Root types + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("user") + .type(typeRef("User"))) + .field(newFieldDefinition() + .name("search") + .argument(newArgument() + .name("input") + .type(typeRef("SearchInput"))) + .type(typeRef("SearchResult"))) + .field(newFieldDefinition() + .name("status") + .type(typeRef("Status"))) + .field(newFieldDefinition() + .name("timestamp") + .type(typeRef("DateTime"))) + .build() + + def mutationType = newObject() + .name("Mutation") + .field(newFieldDefinition() + .name("updateUser") + .argument(newArgument() + .name("input") + .type(typeRef("UserInput"))) + .type(typeRef("User"))) + .build() + + def subscriptionType = newObject() + .name("Subscription") + .field(newFieldDefinition() + .name("userUpdated") + .type(typeRef("User"))) + .build() + + and: "code registry with type resolvers" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + .typeResolver("SearchResult", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + mutationType, + subscriptionType, + [dateTimeScalar, statusEnum, nodeInterface, userInput, searchInput, userType, postType, searchResultUnion], + [], + codeRegistry + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "schema with circular type references matches between FastBuilder and standard builder"() { + given: "SDL with circular references" + def sdl = """ + type Query { + person: Person + } + + type Person { + name: String! + friends: [Person!]! + bestFriend: Person + } + """ + + and: "programmatically created types with circular references" + def personType = newObject() + .name("Person") + .field(newFieldDefinition() + .name("name") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newFieldDefinition() + .name("friends") + .type(GraphQLNonNull.nonNull(GraphQLList.list(GraphQLNonNull.nonNull(typeRef("Person")))))) + .field(newFieldDefinition() + .name("bestFriend") + .type(typeRef("Person"))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("person") + .type(typeRef("Person"))) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [personType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "schema with deeply nested types matches between FastBuilder and standard builder"() { + given: "SDL with deeply nested types" + def sdl = """ + type Query { + data: Level1 + } + + type Level1 { + value: String! + nested: Level2 + } + + type Level2 { + value: String! + nested: Level3 + } + + type Level3 { + value: String! + nested: Level4 + } + + type Level4 { + value: String! + items: [Level1!]! + } + """ + + and: "programmatically created nested types" + def level4Type = newObject() + .name("Level4") + .field(newFieldDefinition() + .name("value") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newFieldDefinition() + .name("items") + .type(GraphQLNonNull.nonNull(GraphQLList.list(GraphQLNonNull.nonNull(typeRef("Level1")))))) + .build() + + def level3Type = newObject() + .name("Level3") + .field(newFieldDefinition() + .name("value") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newFieldDefinition() + .name("nested") + .type(typeRef("Level4"))) + .build() + + def level2Type = newObject() + .name("Level2") + .field(newFieldDefinition() + .name("value") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newFieldDefinition() + .name("nested") + .type(typeRef("Level3"))) + .build() + + def level1Type = newObject() + .name("Level1") + .field(newFieldDefinition() + .name("value") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .field(newFieldDefinition() + .name("nested") + .type(typeRef("Level2"))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("data") + .type(typeRef("Level1"))) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [level1Type, level2Type, level3Type, level4Type] + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "schema with mutation and subscription types matches between FastBuilder and standard builder"() { + given: "SDL with all root types" + def sdl = """ + type Query { + getValue: String + } + + type Mutation { + setValue(value: String!): String + deleteValue: Boolean + } + + type Subscription { + valueChanged: String + valueDeleted: Boolean + } + """ + + and: "programmatically created root types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("getValue") + .type(GraphQLString)) + .build() + + def mutationType = newObject() + .name("Mutation") + .field(newFieldDefinition() + .name("setValue") + .argument(newArgument() + .name("value") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .type(GraphQLString)) + .field(newFieldDefinition() + .name("deleteValue") + .type(GraphQLBoolean)) + .build() + + def subscriptionType = newObject() + .name("Subscription") + .field(newFieldDefinition() + .name("valueChanged") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("valueDeleted") + .type(GraphQLBoolean)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, mutationType, subscriptionType) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + // ==================== Directives ==================== + + def "schema with custom directives matches between FastBuilder and standard builder"() { + given: "SDL with custom directives" + def sdl = """ + directive @auth(requires: Role = ADMIN) on FIELD_DEFINITION + directive @cache(ttl: Int!) on FIELD_DEFINITION + directive @deprecated(reason: String = "No longer supported") on FIELD_DEFINITION + + enum Role { + ADMIN + USER + GUEST + } + + type Query { + publicField: String + protectedField: String @auth(requires: ADMIN) + cachedField: String @cache(ttl: 300) + } + """ + + and: "programmatically created directives and types" + def roleEnum = newEnum() + .name("Role") + .value("ADMIN") + .value("USER") + .value("GUEST") + .build() + + def authDirective = newDirective() + .name("auth") + .validLocation(DirectiveLocation.FIELD_DEFINITION) + .argument(newArgument() + .name("requires") + .type(typeRef("Role")) + .defaultValueProgrammatic("ADMIN")) + .build() + + def cacheDirective = newDirective() + .name("cache") + .validLocation(DirectiveLocation.FIELD_DEFINITION) + .argument(newArgument() + .name("ttl") + .type(GraphQLNonNull.nonNull(GraphQLInt))) + .build() + + def deprecatedDirective = newDirective() + .name("deprecated") + .validLocation(DirectiveLocation.FIELD_DEFINITION) + .argument(newArgument() + .name("reason") + .type(GraphQLString) + .defaultValueProgrammatic("No longer supported")) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("publicField") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("protectedField") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("cachedField") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [roleEnum], + [authDirective, cacheDirective, deprecatedDirective] + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "custom directive definitions match" + def fastAuth = fastSchema.getDirective("auth") + def standardAuth = standardSchema.getDirective("auth") + fastAuth != null + standardAuth != null + fastAuth.name == standardAuth.name + fastAuth.validLocations() == standardAuth.validLocations() + fastAuth.getArgument("requires") != null + standardAuth.getArgument("requires") != null + + def fastCache = fastSchema.getDirective("cache") + def standardCache = standardSchema.getDirective("cache") + fastCache != null + standardCache != null + fastCache.name == standardCache.name + fastCache.getArgument("ttl") != null + standardCache.getArgument("ttl") != null + } + + def "schema with applied directives on types matches between FastBuilder and standard builder"() { + given: "SDL with applied directives on types" + def sdl = """ + directive @entity(tableName: String!) on OBJECT + directive @deprecated(reason: String) on ENUM | ENUM_VALUE + + type Query { + user: User + status: Status + } + + type User @entity(tableName: "users") { + id: ID! + name: String! + } + + enum Status @deprecated(reason: "Use StatusV2") { + ACTIVE @deprecated(reason: "Use ENABLED") + INACTIVE + } + """ + + and: "programmatically created directives and types" + def entityDirective = newDirective() + .name("entity") + .validLocation(DirectiveLocation.OBJECT) + .argument(newArgument() + .name("tableName") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .build() + + def deprecatedDirective = newDirective() + .name("deprecated") + .validLocations(DirectiveLocation.ENUM, DirectiveLocation.ENUM_VALUE) + .argument(newArgument() + .name("reason") + .type(GraphQLString)) + .build() + + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("id") + .type(GraphQLNonNull.nonNull(Scalars.GraphQLID))) + .field(newFieldDefinition() + .name("name") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .build() + + def statusEnum = newEnum() + .name("Status") + .value("ACTIVE") + .value("INACTIVE") + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("user") + .type(typeRef("User"))) + .field(newFieldDefinition() + .name("status") + .type(typeRef("Status"))) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [userType, statusEnum], + [entityDirective, deprecatedDirective] + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "schema-level applied directives match between FastBuilder and standard builder"() { + given: "SDL with schema-level directives" + def sdl = """ + schema @link(url: "https://example.com/spec") { + query: Query + } + + directive @link(url: String!) on SCHEMA + + type Query { + value: String + } + """ + + and: "programmatically created directive and schema" + def linkDirective = newDirective() + .name("link") + .validLocation(DirectiveLocation.SCHEMA) + .argument(newArgument() + .name("url") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [], + [linkDirective] + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "built-in directives are present in both FastBuilder and standard builder schemas"() { + given: "minimal SDL" + def sdl = """ + type Query { + value: String + } + """ + + and: "programmatically created query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "all built-in directives are present in both" + def builtInDirectives = ["skip", "include", "deprecated", "specifiedBy"] + builtInDirectives.each { directiveName -> + assert fastSchema.getDirective(directiveName) != null, + "FastBuilder schema missing built-in directive: ${directiveName}" + assert standardSchema.getDirective(directiveName) != null, + "Standard schema missing built-in directive: ${directiveName}" + } + } + + def "schema with directives on multiple locations matches between FastBuilder and standard builder"() { + given: "SDL with directives on various locations" + def sdl = """ + directive @meta(info: String!) on OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | INPUT_OBJECT | INPUT_FIELD_DEFINITION + + type Query { + search( + query: String! @meta(info: "Search query") + limit: Int @meta(info: "Result limit") + ): SearchResult @meta(info: "Search results") + } + + type User @meta(info: "User type") { + name: String! @meta(info: "User name") + } + + type Post @meta(info: "Post type") { + title: String! @meta(info: "Post title") + } + + union SearchResult @meta(info: "Search result union") = User | Post + + interface Node @meta(info: "Node interface") { + id: ID! @meta(info: "Node ID") + } + + enum Status @meta(info: "Status enum") { + ACTIVE + INACTIVE + } + + input UserInput @meta(info: "User input") { + name: String! @meta(info: "Input name") + } + """ + + and: "programmatically created directive and types" + def metaDirective = newDirective() + .name("meta") + .validLocations( + DirectiveLocation.OBJECT, + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.ARGUMENT_DEFINITION, + DirectiveLocation.INTERFACE, + DirectiveLocation.UNION, + DirectiveLocation.ENUM, + DirectiveLocation.INPUT_OBJECT, + DirectiveLocation.INPUT_FIELD_DEFINITION + ) + .argument(newArgument() + .name("info") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .build() + + def nodeInterface = newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLNonNull.nonNull(Scalars.GraphQLID))) + .build() + + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .build() + + def postType = newObject() + .name("Post") + .field(newFieldDefinition() + .name("title") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .build() + + def searchResultUnion = newUnionType() + .name("SearchResult") + .possibleType(typeRef("User")) + .possibleType(typeRef("Post")) + .build() + + def statusEnum = newEnum() + .name("Status") + .value("ACTIVE") + .value("INACTIVE") + .build() + + def userInput = newInputObject() + .name("UserInput") + .field(newInputObjectField() + .name("name") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("search") + .argument(newArgument() + .name("query") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .argument(newArgument() + .name("limit") + .type(GraphQLInt)) + .type(typeRef("SearchResult"))) + .build() + + and: "code registry with type resolvers" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + .typeResolver("SearchResult", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [nodeInterface, userType, postType, searchResultUnion, statusEnum, userInput], + [metaDirective], + codeRegistry + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "meta directive definition matches" + def fastMeta = fastSchema.getDirective("meta") + def standardMeta = standardSchema.getDirective("meta") + fastMeta != null + standardMeta != null + fastMeta.name == standardMeta.name + fastMeta.validLocations().toSet() == standardMeta.validLocations().toSet() + } + + def "schema with repeatable directives matches between FastBuilder and standard builder"() { + given: "SDL with repeatable directive" + def sdl = """ + directive @tag(name: String!) repeatable on FIELD_DEFINITION + + type Query { + value: String @tag(name: "public") @tag(name: "cached") + } + """ + + and: "programmatically created repeatable directive" + def tagDirective = newDirective() + .name("tag") + .repeatable(true) + .validLocation(DirectiveLocation.FIELD_DEFINITION) + .argument(newArgument() + .name("name") + .type(GraphQLNonNull.nonNull(GraphQLString))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [], + [tagDirective] + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "directive is repeatable in both" + def fastTag = fastSchema.getDirective("tag") + def standardTag = standardSchema.getDirective("tag") + fastTag.isRepeatable() == standardTag.isRepeatable() + fastTag.isRepeatable() == true + } +} diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonInterfaceTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonInterfaceTest.groovy new file mode 100644 index 000000000..76636cdcc --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonInterfaceTest.groovy @@ -0,0 +1,741 @@ +package graphql.schema + +import graphql.Scalars +import spock.lang.Specification + +import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLObjectType.newObject +import static graphql.schema.GraphQLTypeReference.typeRef + +/** + * Comparison tests for interface implementations between FastBuilder and standard Builder. + * + * These tests verify that FastBuilder tracks interface implementations correctly and in + * the same sorted order as the standard Builder (alphabetically by type name). + * + * CRITICAL: getImplementations() returns a LIST and order matters - implementations + * should be sorted alphabetically by name. + */ +class FastBuilderComparisonInterfaceTest extends FastBuilderComparisonTest { + + def "single interface with multiple implementations - getImplementations matches in sorted order"() { + given: "SDL with interface and multiple implementations" + def sdl = """ + type Query { + entity: Entity + } + + interface Entity { + id: String + } + + type Zebra implements Entity { + id: String + stripes: String + } + + type Aardvark implements Entity { + id: String + tongue: String + } + + type Meerkat implements Entity { + id: String + burrow: String + } + """ + + and: "programmatically created types" + def entityInterface = GraphQLInterfaceType.newInterface() + .name("Entity") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def zebraType = newObject() + .name("Zebra") + .withInterface(typeRef("Entity")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("stripes") + .type(GraphQLString)) + .build() + + def aardvarkType = newObject() + .name("Aardvark") + .withInterface(typeRef("Entity")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("tongue") + .type(GraphQLString)) + .build() + + def meerkatType = newObject() + .name("Meerkat") + .withInterface(typeRef("Entity")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("burrow") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("entity") + .type(entityInterface)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Entity", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [entityInterface, zebraType, aardvarkType, meerkatType], + [], + codeRegistry + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "implementations are in sorted order (Aardvark, Meerkat, Zebra)" + def fastImpls = fastSchema.getImplementations(fastSchema.getType("Entity") as GraphQLInterfaceType)*.name + def standardImpls = standardSchema.getImplementations(standardSchema.getType("Entity") as GraphQLInterfaceType)*.name + + fastImpls == ["Aardvark", "Meerkat", "Zebra"] + fastImpls == standardImpls + } + + def "multiple interfaces with overlapping implementations - all getImplementations match"() { + given: "SDL with multiple interfaces and overlapping implementations" + def sdl = """ + type Query { + node: Node + named: Named + } + + interface Node { + id: String + } + + interface Named { + name: String + } + + type User implements Node & Named { + id: String + name: String + email: String + } + + type Product implements Node & Named { + id: String + name: String + price: Float + } + + type Comment implements Node { + id: String + text: String + } + """ + + and: "programmatically created types" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def namedInterface = GraphQLInterfaceType.newInterface() + .name("Named") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def userType = newObject() + .name("User") + .withInterface(typeRef("Node")) + .withInterface(typeRef("Named")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("email") + .type(GraphQLString)) + .build() + + def productType = newObject() + .name("Product") + .withInterface(typeRef("Node")) + .withInterface(typeRef("Named")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("price") + .type(Scalars.GraphQLFloat)) + .build() + + def commentType = newObject() + .name("Comment") + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("text") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .field(newFieldDefinition() + .name("named") + .type(namedInterface)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + .typeResolver("Named", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [nodeInterface, namedInterface, userType, productType, commentType], + [], + codeRegistry + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "Node interface has 3 implementations in sorted order" + def fastNodeImpls = fastSchema.getImplementations(fastSchema.getType("Node") as GraphQLInterfaceType)*.name + def standardNodeImpls = standardSchema.getImplementations(standardSchema.getType("Node") as GraphQLInterfaceType)*.name + + fastNodeImpls == ["Comment", "Product", "User"] + fastNodeImpls == standardNodeImpls + + and: "Named interface has 2 implementations in sorted order" + def fastNamedImpls = fastSchema.getImplementations(fastSchema.getType("Named") as GraphQLInterfaceType)*.name + def standardNamedImpls = standardSchema.getImplementations(standardSchema.getType("Named") as GraphQLInterfaceType)*.name + + fastNamedImpls == ["Product", "User"] + fastNamedImpls == standardNamedImpls + } + + def "interface extending interface - getImplementations matches for both interfaces"() { + given: "SDL with interface inheritance" + def sdl = """ + type Query { + node: Node + namedNode: NamedNode + } + + interface Node { + id: String + } + + interface NamedNode implements Node { + id: String + name: String + } + + type Person implements NamedNode & Node { + id: String + name: String + age: Int + } + + type Organization implements NamedNode & Node { + id: String + name: String + address: String + } + + type Document implements Node { + id: String + title: String + } + """ + + and: "programmatically created types" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def namedNodeInterface = GraphQLInterfaceType.newInterface() + .name("NamedNode") + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def personType = newObject() + .name("Person") + .withInterface(typeRef("NamedNode")) + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("age") + .type(Scalars.GraphQLInt)) + .build() + + def organizationType = newObject() + .name("Organization") + .withInterface(typeRef("NamedNode")) + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("address") + .type(GraphQLString)) + .build() + + def documentType = newObject() + .name("Document") + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("title") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .field(newFieldDefinition() + .name("namedNode") + .type(namedNodeInterface)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + .typeResolver("NamedNode", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [nodeInterface, namedNodeInterface, personType, organizationType, documentType], + [], + codeRegistry + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "Node interface has 3 object implementations in sorted order" + def fastNodeImpls = fastSchema.getImplementations(fastSchema.getType("Node") as GraphQLInterfaceType)*.name + def standardNodeImpls = standardSchema.getImplementations(standardSchema.getType("Node") as GraphQLInterfaceType)*.name + + fastNodeImpls == ["Document", "Organization", "Person"] + fastNodeImpls == standardNodeImpls + + and: "NamedNode interface has 2 implementations in sorted order" + def fastNamedNodeImpls = fastSchema.getImplementations(fastSchema.getType("NamedNode") as GraphQLInterfaceType)*.name + def standardNamedNodeImpls = standardSchema.getImplementations(standardSchema.getType("NamedNode") as GraphQLInterfaceType)*.name + + fastNamedNodeImpls == ["Organization", "Person"] + fastNamedNodeImpls == standardNamedNodeImpls + } + + def "object implementing multiple interfaces - tracked correctly in all interfaces"() { + given: "SDL with object implementing multiple interfaces" + def sdl = """ + type Query { + entity: Entity + timestamped: Timestamped + versioned: Versioned + } + + interface Entity { + id: String + } + + interface Timestamped { + createdAt: String + updatedAt: String + } + + interface Versioned { + version: Int + } + + type Article implements Entity & Timestamped & Versioned { + id: String + createdAt: String + updatedAt: String + version: Int + title: String + } + + type BasicEntity implements Entity { + id: String + } + """ + + and: "programmatically created types" + def entityInterface = GraphQLInterfaceType.newInterface() + .name("Entity") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def timestampedInterface = GraphQLInterfaceType.newInterface() + .name("Timestamped") + .field(newFieldDefinition() + .name("createdAt") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("updatedAt") + .type(GraphQLString)) + .build() + + def versionedInterface = GraphQLInterfaceType.newInterface() + .name("Versioned") + .field(newFieldDefinition() + .name("version") + .type(Scalars.GraphQLInt)) + .build() + + def articleType = newObject() + .name("Article") + .withInterface(typeRef("Entity")) + .withInterface(typeRef("Timestamped")) + .withInterface(typeRef("Versioned")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("createdAt") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("updatedAt") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("version") + .type(Scalars.GraphQLInt)) + .field(newFieldDefinition() + .name("title") + .type(GraphQLString)) + .build() + + def basicEntityType = newObject() + .name("BasicEntity") + .withInterface(typeRef("Entity")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("entity") + .type(entityInterface)) + .field(newFieldDefinition() + .name("timestamped") + .type(timestampedInterface)) + .field(newFieldDefinition() + .name("versioned") + .type(versionedInterface)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Entity", { env -> null }) + .typeResolver("Timestamped", { env -> null }) + .typeResolver("Versioned", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [entityInterface, timestampedInterface, versionedInterface, articleType, basicEntityType], + [], + codeRegistry + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "Entity interface has both implementations" + def fastEntityImpls = fastSchema.getImplementations(fastSchema.getType("Entity") as GraphQLInterfaceType)*.name + def standardEntityImpls = standardSchema.getImplementations(standardSchema.getType("Entity") as GraphQLInterfaceType)*.name + + fastEntityImpls == ["Article", "BasicEntity"] + fastEntityImpls == standardEntityImpls + + and: "Timestamped interface has only Article" + def fastTimestampedImpls = fastSchema.getImplementations(fastSchema.getType("Timestamped") as GraphQLInterfaceType)*.name + def standardTimestampedImpls = standardSchema.getImplementations(standardSchema.getType("Timestamped") as GraphQLInterfaceType)*.name + + fastTimestampedImpls == ["Article"] + fastTimestampedImpls == standardTimestampedImpls + + and: "Versioned interface has only Article" + def fastVersionedImpls = fastSchema.getImplementations(fastSchema.getType("Versioned") as GraphQLInterfaceType)*.name + def standardVersionedImpls = standardSchema.getImplementations(standardSchema.getType("Versioned") as GraphQLInterfaceType)*.name + + fastVersionedImpls == ["Article"] + fastVersionedImpls == standardVersionedImpls + } + + def "many implementations of single interface - alphabetical sort order preserved"() { + given: "SDL with many implementations" + def sdl = """ + type Query { + animal: Animal + } + + interface Animal { + name: String + } + + type Zebra implements Animal { + name: String + } + + type Yak implements Animal { + name: String + } + + type Wolf implements Animal { + name: String + } + + type Elephant implements Animal { + name: String + } + + type Dog implements Animal { + name: String + } + + type Cat implements Animal { + name: String + } + + type Bear implements Animal { + name: String + } + + type Aardvark implements Animal { + name: String + } + """ + + and: "programmatically created types" + def animalInterface = GraphQLInterfaceType.newInterface() + .name("Animal") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def zebraType = newObject() + .name("Zebra") + .withInterface(typeRef("Animal")) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def yakType = newObject() + .name("Yak") + .withInterface(typeRef("Animal")) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def wolfType = newObject() + .name("Wolf") + .withInterface(typeRef("Animal")) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def elephantType = newObject() + .name("Elephant") + .withInterface(typeRef("Animal")) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def dogType = newObject() + .name("Dog") + .withInterface(typeRef("Animal")) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def catType = newObject() + .name("Cat") + .withInterface(typeRef("Animal")) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def bearType = newObject() + .name("Bear") + .withInterface(typeRef("Animal")) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def aardvarkType = newObject() + .name("Aardvark") + .withInterface(typeRef("Animal")) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("animal") + .type(animalInterface)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Animal", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [animalInterface, zebraType, yakType, wolfType, elephantType, dogType, catType, bearType, aardvarkType], + [], + codeRegistry + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "all implementations are in strict alphabetical order" + def fastImpls = fastSchema.getImplementations(fastSchema.getType("Animal") as GraphQLInterfaceType)*.name + def standardImpls = standardSchema.getImplementations(standardSchema.getType("Animal") as GraphQLInterfaceType)*.name + + fastImpls == ["Aardvark", "Bear", "Cat", "Dog", "Elephant", "Wolf", "Yak", "Zebra"] + fastImpls == standardImpls + } + + def "interface with no implementations - empty list matches"() { + given: "SDL with interface that has no implementations" + def sdl = """ + type Query { + unused: Unused + value: String + } + + interface Unused { + id: String + } + """ + + and: "programmatically created types" + def unusedInterface = GraphQLInterfaceType.newInterface() + .name("Unused") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("unused") + .type(unusedInterface)) + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Unused", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, + null, + null, + [unusedInterface], + [], + codeRegistry + ) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "both return empty list for implementations" + def fastImpls = fastSchema.getImplementations(fastSchema.getType("Unused") as GraphQLInterfaceType) + def standardImpls = standardSchema.getImplementations(standardSchema.getType("Unused") as GraphQLInterfaceType) + + fastImpls.isEmpty() + standardImpls.isEmpty() + fastImpls*.name == standardImpls*.name + } +} diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonMigratedTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonMigratedTest.groovy new file mode 100644 index 000000000..7a85787c7 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonMigratedTest.groovy @@ -0,0 +1,397 @@ +package graphql.schema + +import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLEnumType.newEnum +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLObjectType.newObject +import static graphql.schema.GraphQLScalarType.newScalar + +/** + * Comparison tests migrated from FastBuilderTest.groovy. + * + * These tests were originally in FastBuilderTest but have been refactored to use + * the comparison testing approach: building schemas with both FastBuilder and SDL + * parsing, then asserting equivalence. + */ +class FastBuilderComparisonMigratedTest extends FastBuilderComparisonTest { + + // ==================== Migrated Tests ==================== + + def "scalar type schema matches standard builder"() { + given: "SDL for a schema with custom scalar" + def sdl = """ + scalar CustomScalar + + type Query { + value: CustomScalar + } + """ + + and: "programmatically created types" + def customScalar = newScalar() + .name("CustomScalar") + .coercing(GraphQLString.getCoercing()) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(customScalar)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [customScalar]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "enum type schema matches standard builder"() { + given: "SDL for a schema with enum" + def sdl = """ + enum Status { + ACTIVE + INACTIVE + } + + type Query { + status: Status + } + """ + + and: "programmatically created types" + def statusEnum = newEnum() + .name("Status") + .value("ACTIVE") + .value("INACTIVE") + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("status") + .type(statusEnum)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [statusEnum]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "built-in directives are added automatically in both builders"() { + given: "SDL for minimal schema" + def sdl = """ + type Query { + value: String + } + """ + + and: "programmatically created query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType) + + then: "both schemas have the same built-in directives" + def coreDirectives = ["skip", "include", "deprecated", "specifiedBy"] + + and: "FastBuilder has all core directives" + coreDirectives.each { directiveName -> + assert fastSchema.getDirective(directiveName) != null, + "FastBuilder missing directive: ${directiveName}" + } + + and: "standard builder has all core directives" + coreDirectives.each { directiveName -> + assert standardSchema.getDirective(directiveName) != null, + "Standard builder missing directive: ${directiveName}" + } + + and: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "mutation and subscription types work correctly in both builders"() { + given: "SDL for schema with all root types" + def sdl = """ + type Query { + value: String + } + + type Mutation { + setValue(input: String): String + } + + type Subscription { + valueChanged: String + } + """ + + and: "programmatically created root types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def mutationType = newObject() + .name("Mutation") + .field(newFieldDefinition() + .name("setValue") + .argument(GraphQLArgument.newArgument() + .name("input") + .type(GraphQLString)) + .type(GraphQLString)) + .build() + + def subscriptionType = newObject() + .name("Subscription") + .field(newFieldDefinition() + .name("valueChanged") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, mutationType, subscriptionType) + + then: "both schemas support mutations and subscriptions" + fastSchema.isSupportingMutations() + standardSchema.isSupportingMutations() + fastSchema.isSupportingSubscriptions() + standardSchema.isSupportingSubscriptions() + + and: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "schema with only query type works correctly in both builders"() { + given: "SDL for schema with only Query" + def sdl = """ + type Query { + value: String + } + """ + + and: "programmatically created query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with both approaches (no mutation or subscription)" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null) + + then: "both schemas do not support mutations or subscriptions" + !fastSchema.isSupportingMutations() + !standardSchema.isSupportingMutations() + !fastSchema.isSupportingSubscriptions() + !standardSchema.isSupportingSubscriptions() + + and: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "schema description is preserved in both builders"() { + given: "SDL for schema with description" + def schemaDescription = "Test schema description" + def sdl = """ + \"\"\" + ${schemaDescription} + \"\"\" + schema { + query: Query + } + + type Query { + value: String + } + """ + + and: "programmatically created query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building standard schema from SDL" + def standardSchema = buildSchemaFromSDL(sdl) + + and: "building FastBuilder schema with description" + def fastSchema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .description(schemaDescription) + .build() + + then: "both schemas have the same description" + fastSchema.description == schemaDescription + standardSchema.description == schemaDescription + + and: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "schema with mutation but no subscription works correctly"() { + given: "SDL for schema with Query and Mutation only" + def sdl = """ + type Query { + value: String + } + + type Mutation { + setValue(input: String): String + } + """ + + and: "programmatically created types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def mutationType = newObject() + .name("Mutation") + .field(newFieldDefinition() + .name("setValue") + .argument(GraphQLArgument.newArgument() + .name("input") + .type(GraphQLString)) + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, mutationType, null) + + then: "both schemas support mutations but not subscriptions" + fastSchema.isSupportingMutations() + standardSchema.isSupportingMutations() + !fastSchema.isSupportingSubscriptions() + !standardSchema.isSupportingSubscriptions() + + and: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } + + def "schema with multiple scalar types matches standard builder"() { + given: "SDL for schema with multiple custom scalars" + def sdl = """ + scalar Scalar1 + scalar Scalar2 + + type Query { + value1: Scalar1 + value2: Scalar2 + } + """ + + and: "programmatically created types" + def scalar1 = newScalar() + .name("Scalar1") + .coercing(GraphQLString.getCoercing()) + .build() + + def scalar2 = newScalar() + .name("Scalar2") + .coercing(GraphQLString.getCoercing()) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value1") + .type(scalar1)) + .field(newFieldDefinition() + .name("value2") + .type(scalar2)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [scalar1, scalar2]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "both scalars are present in both schemas" + fastSchema.getType("Scalar1") != null + fastSchema.getType("Scalar2") != null + standardSchema.getType("Scalar1") != null + standardSchema.getType("Scalar2") != null + } + + def "schema with multiple enum types matches standard builder"() { + given: "SDL for schema with multiple enums" + def sdl = """ + enum Status { + ACTIVE + INACTIVE + } + + enum Role { + ADMIN + USER + } + + type Query { + status: Status + role: Role + } + """ + + and: "programmatically created types" + def statusEnum = newEnum() + .name("Status") + .value("ACTIVE") + .value("INACTIVE") + .build() + + def roleEnum = newEnum() + .name("Role") + .value("ADMIN") + .value("USER") + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("status") + .type(statusEnum)) + .field(newFieldDefinition() + .name("role") + .type(roleEnum)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [statusEnum, roleEnum]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "both enums are present in both schemas" + fastSchema.getType("Status") instanceof GraphQLEnumType + fastSchema.getType("Role") instanceof GraphQLEnumType + standardSchema.getType("Status") instanceof GraphQLEnumType + standardSchema.getType("Role") instanceof GraphQLEnumType + } +} diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy new file mode 100644 index 000000000..0d5fd8437 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy @@ -0,0 +1,195 @@ +package graphql.schema + +import graphql.TestUtil +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.SchemaGenerator +import graphql.schema.idl.SchemaParser +import graphql.schema.idl.TestMockedWiringFactory +import spock.lang.Specification + +import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLObjectType.newObject + +/** + * Comparison tests for FastBuilder vs standard Builder. + * + * This is the PRIMARY way to verify FastBuilder correctness - by comparing schemas + * built with FastBuilder against identical schemas built via SDL parsing with the + * standard Builder. This ensures FastBuilder maintains semantic equivalence with + * real-world schema construction. + * + * The asymmetry is intentional: SDL → SchemaParser → standard Builder vs direct + * FastBuilder calls. This verifies FastBuilder produces the same result as the + * production SDL parsing path. + */ +class FastBuilderComparisonTest extends Specification { + + // ==================== Helper Functions ==================== + + /** + * Builds a schema from SDL using the standard path: + * SDL → SchemaParser → SchemaGenerator → standard Builder + */ + GraphQLSchema buildSchemaFromSDL(String sdl) { + def typeRegistry = new SchemaParser().parse(sdl) + def wiring = RuntimeWiring.newRuntimeWiring() + .wiringFactory(new TestMockedWiringFactory()) + .build() + def options = SchemaGenerator.Options.defaultOptions() + return new SchemaGenerator().makeExecutableSchema(options, typeRegistry, wiring) + } + + /** + * Builds a schema using FastBuilder with programmatically created types. + * + * @param queryType The query root type (required) + * @param mutationType The mutation root type (optional) + * @param subscriptionType The subscription root type (optional) + * @param additionalTypes Additional types to add to schema + * @param additionalDirectives Additional directives to add to schema + * @param codeRegistry The code registry builder (optional, creates new if null) + * @return The built GraphQLSchema + */ + GraphQLSchema buildSchemaWithFastBuilder( + GraphQLObjectType queryType, + GraphQLObjectType mutationType = null, + GraphQLObjectType subscriptionType = null, + List additionalTypes = [], + List additionalDirectives = [], + GraphQLCodeRegistry.Builder codeRegistry = null + ) { + def registry = codeRegistry ?: GraphQLCodeRegistry.newCodeRegistry() + + def builder = new GraphQLSchema.FastBuilder(registry, queryType, mutationType, subscriptionType) + + additionalTypes.each { type -> + if (type != null) { + builder.additionalType(type) + } + } + + additionalDirectives.each { directive -> + if (directive != null) { + builder.additionalDirective(directive) + } + } + + return builder.build() + } + + // ==================== Assertion Helpers ==================== + + /** + * Built-in scalar type names that may differ between FastBuilder and standard Builder. + */ + private static final Set BUILT_IN_SCALARS = [ + "String", "Int", "Float", "Boolean", "ID" + ].toSet() + + /** + * Filters out introspection types (types starting with "__") and built-in scalars + * from a type name set. + */ + private Set filterSystemTypes(Set typeNames) { + typeNames.findAll { !it.startsWith("__") && !BUILT_IN_SCALARS.contains(it) }.toSet() + } + + /** + * Asserts that two schemas are semantically equivalent. + * + * This checks: + * - Type map keys match (excluding introspection types and built-in scalars) + * - Additional types match (as sets, order doesn't matter) + * - Interface implementations match (as lists, order matters - alphabetically sorted) + * - Core directive names are present (allows experimental directives to differ) + * - Root types match + */ + void assertSchemasEquivalent(GraphQLSchema fastSchema, GraphQLSchema standardSchema) { + // Check type map keys match (excluding introspection types and built-in scalars which may differ) + def fastTypes = filterSystemTypes(fastSchema.typeMap.keySet()) + def standardTypes = filterSystemTypes(standardSchema.typeMap.keySet()) + assert fastTypes == standardTypes, + "Type map keys differ:\n" + + "FastBuilder types: ${fastTypes}\n" + + "Standard types: ${standardTypes}" + + // Check additional types match (as sets - order doesn't matter for detached types) + def fastAdditionalTypes = fastSchema.additionalTypes*.name.toSet() + def standardAdditionalTypes = standardSchema.additionalTypes*.name.toSet() + assert fastAdditionalTypes == standardAdditionalTypes, + "Additional types differ:\n" + + "FastBuilder: ${fastAdditionalTypes}\n" + + "Standard: ${standardAdditionalTypes}" + + // Check interface implementations (order matters - should be alphabetically sorted) + // Only check user-defined interfaces (not introspection interfaces) + def interfaces = fastSchema.allTypesAsList.findAll { + it instanceof GraphQLInterfaceType && !it.name.startsWith("__") + } + interfaces.each { GraphQLInterfaceType iface -> + def fastImpls = fastSchema.getImplementations(iface)*.name + def standardImpls = standardSchema.getImplementations(iface)*.name + assert fastImpls == standardImpls, + "Interface '${iface.name}' implementations differ:\n" + + "FastBuilder: ${fastImpls}\n" + + "Standard: ${standardImpls}" + } + + // Check directive names match (as sets - order may vary) + // Note: We check that core directives are present, but allow experimental directives to differ + def coreDirectives = ["include", "skip", "deprecated", "specifiedBy"].toSet() + def fastDirectiveNames = fastSchema.directives*.name.toSet() + def standardDirectiveNames = standardSchema.directives*.name.toSet() + + assert fastDirectiveNames.containsAll(coreDirectives), + "FastBuilder missing core directives: ${coreDirectives - fastDirectiveNames}" + assert standardDirectiveNames.containsAll(coreDirectives), + "Standard builder missing core directives: ${coreDirectives - standardDirectiveNames}" + + // Check that FastBuilder directives are a subset of standard directives + // (standard may have additional experimental directives) + assert standardDirectiveNames.containsAll(fastDirectiveNames), + "FastBuilder has directives not in standard builder: ${fastDirectiveNames - standardDirectiveNames}" + + // Check root types + assert fastSchema.queryType?.name == standardSchema.queryType?.name, + "Query types differ: ${fastSchema.queryType?.name} vs ${standardSchema.queryType?.name}" + + if (fastSchema.mutationType || standardSchema.mutationType) { + assert fastSchema.mutationType?.name == standardSchema.mutationType?.name, + "Mutation types differ: ${fastSchema.mutationType?.name} vs ${standardSchema.mutationType?.name}" + } + + if (fastSchema.subscriptionType || standardSchema.subscriptionType) { + assert fastSchema.subscriptionType?.name == standardSchema.subscriptionType?.name, + "Subscription types differ: ${fastSchema.subscriptionType?.name} vs ${standardSchema.subscriptionType?.name}" + } + } + + // ==================== Smoke Test ==================== + + def "trivial schema with one String field matches between FastBuilder and standard builder"() { + given: "SDL for a trivial schema" + def sdl = """ + type Query { + value: String + } + """ + + and: "programmatically created query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + } +} diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonTypeRefTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonTypeRefTest.groovy new file mode 100644 index 000000000..7c44f17f7 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonTypeRefTest.groovy @@ -0,0 +1,1041 @@ +package graphql.schema + +import graphql.Scalars +import graphql.introspection.Introspection +import spock.lang.Specification + +import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLArgument.newArgument +import static graphql.schema.GraphQLDirective.newDirective +import static graphql.schema.GraphQLAppliedDirective.newDirective as newAppliedDirective +import static graphql.schema.GraphQLAppliedDirectiveArgument.newArgument as newAppliedArgument +import static graphql.schema.GraphQLEnumType.newEnum +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLInputObjectField.newInputObjectField +import static graphql.schema.GraphQLInputObjectType.newInputObject +import static graphql.schema.GraphQLObjectType.newObject +import static graphql.schema.GraphQLScalarType.newScalar +import static graphql.schema.GraphQLTypeReference.typeRef + +/** + * Comparison tests for Type Reference Resolution in FastBuilder. + * + * These tests verify that FastBuilder correctly resolves GraphQLTypeReference instances + * to their concrete types, matching the behavior of SDL-based schema construction. + * + * Pattern: For each test, we define SDL, build schema via SDL parsing, then create + * equivalent types programmatically using typeRef(), build with FastBuilder, and + * verify the schemas are equivalent AND that specific type references are resolved. + */ +class FastBuilderComparisonTypeRefTest extends FastBuilderComparisonTest { + + // ==================== Object Type with Type Reference Fields ==================== + + def "object type with type reference field resolves correctly"() { + given: "SDL with object type referencing another object" + def sdl = """ + type Query { + person: Person + } + + type Person { + name: String + } + """ + + and: "programmatically created types with type reference" + def personType = newObject() + .name("Person") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("person") + .type(typeRef("Person"))) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [personType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "type reference is resolved in FastBuilder schema" + def queryField = fastSchema.queryType.getFieldDefinition("person") + queryField.getType() == personType + } + + def "object type with NonNull wrapped type reference resolves correctly"() { + given: "SDL with NonNull object reference" + def sdl = """ + type Query { + item: Item! + } + + type Item { + id: String + } + """ + + and: "programmatically created types with NonNull type reference" + def itemType = newObject() + .name("Item") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("item") + .type(GraphQLNonNull.nonNull(typeRef("Item")))) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [itemType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "type reference is resolved with NonNull wrapper" + def queryField = fastSchema.queryType.getFieldDefinition("item") + def fieldType = queryField.getType() + fieldType instanceof GraphQLNonNull + ((GraphQLNonNull) fieldType).getWrappedType() == itemType + } + + def "object type with List wrapped type reference resolves correctly"() { + given: "SDL with List of objects" + def sdl = """ + type Query { + users: [User] + } + + type User { + name: String + } + """ + + and: "programmatically created types with List type reference" + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("users") + .type(GraphQLList.list(typeRef("User")))) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [userType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "type reference is resolved with List wrapper" + def queryField = fastSchema.queryType.getFieldDefinition("users") + def fieldType = queryField.getType() + fieldType instanceof GraphQLList + ((GraphQLList) fieldType).getWrappedType() == userType + } + + def "object type field argument with type reference resolves correctly"() { + given: "SDL with field argument referencing input type" + def sdl = """ + type Query { + search(filter: FilterInput): Result + } + + input FilterInput { + status: String + } + + type Result { + value: String + } + """ + + and: "programmatically created types with type reference in argument" + def filterInput = newInputObject() + .name("FilterInput") + .field(newInputObjectField() + .name("status") + .type(GraphQLString)) + .build() + + def resultType = newObject() + .name("Result") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("search") + .argument(newArgument() + .name("filter") + .type(typeRef("FilterInput"))) + .type(resultType)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [filterInput, resultType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "argument type reference is resolved" + def searchField = fastSchema.queryType.getFieldDefinition("search") + searchField.getArgument("filter").getType() == filterInput + } + + // ==================== Interface Type with Type Reference Fields ==================== + + def "interface type with type reference field resolves correctly"() { + given: "SDL with interface referencing object type" + def sdl = """ + type Query { + node: Node + } + + interface Node { + owner: User + } + + type User { + name: String + } + """ + + and: "programmatically created types with type reference in interface" + def userType = newObject() + .name("User") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("owner") + .type(typeRef("User"))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [nodeInterface, userType], [], codeRegistry) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "interface field type reference is resolved" + def resolvedInterface = fastSchema.getType("Node") as GraphQLInterfaceType + resolvedInterface.getFieldDefinition("owner").getType() == userType + } + + def "interface field argument with type reference resolves correctly"() { + given: "SDL with interface field argument referencing input type" + def sdl = """ + type Query { + searchable: Searchable + } + + interface Searchable { + search(filter: FilterInput): String + } + + input FilterInput { + active: Boolean + } + """ + + and: "programmatically created types with type reference in interface argument" + def filterInput = newInputObject() + .name("FilterInput") + .field(newInputObjectField() + .name("active") + .type(Scalars.GraphQLBoolean)) + .build() + + def searchableInterface = GraphQLInterfaceType.newInterface() + .name("Searchable") + .field(newFieldDefinition() + .name("search") + .argument(newArgument() + .name("filter") + .type(typeRef("FilterInput"))) + .type(GraphQLString)) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("searchable") + .type(searchableInterface)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Searchable", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [searchableInterface, filterInput], [], codeRegistry) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "interface field argument type reference is resolved" + def resolvedInterface = fastSchema.getType("Searchable") as GraphQLInterfaceType + resolvedInterface.getFieldDefinition("search").getArgument("filter").getType() == filterInput + } + + // ==================== Union Type with Type Reference Members ==================== + + def "union type with type reference members resolves correctly"() { + given: "SDL with union of object types" + def sdl = """ + type Query { + pet: Pet + } + + union Pet = Cat | Dog + + type Cat { + meow: String + } + + type Dog { + bark: String + } + """ + + and: "programmatically created types with type references in union" + def catType = newObject() + .name("Cat") + .field(newFieldDefinition() + .name("meow") + .type(GraphQLString)) + .build() + + def dogType = newObject() + .name("Dog") + .field(newFieldDefinition() + .name("bark") + .type(GraphQLString)) + .build() + + def petUnion = GraphQLUnionType.newUnionType() + .name("Pet") + .possibleType(typeRef("Cat")) + .possibleType(typeRef("Dog")) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("pet") + .type(petUnion)) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Pet", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [catType, dogType, petUnion], [], codeRegistry) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "union member type references are resolved" + def resolvedPet = fastSchema.getType("Pet") as GraphQLUnionType + resolvedPet.types.collect { it.name }.toSet() == ["Cat", "Dog"].toSet() + resolvedPet.types[0] in [catType, dogType] + resolvedPet.types[1] in [catType, dogType] + } + + // ==================== Input Object with Type Reference Fields ==================== + + def "input object with type reference field resolves correctly"() { + given: "SDL with input object referencing custom scalar" + def sdl = """ + type Query { + createEvent(input: EventInput): String + } + + input EventInput { + name: String + startDate: DateTime + } + + scalar DateTime + """ + + and: "programmatically created types with type reference in input" + def dateTimeScalar = newScalar() + .name("DateTime") + .coercing(GraphQLString.getCoercing()) + .build() + + def eventInput = newInputObject() + .name("EventInput") + .field(newInputObjectField() + .name("name") + .type(GraphQLString)) + .field(newInputObjectField() + .name("startDate") + .type(typeRef("DateTime"))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("createEvent") + .argument(newArgument() + .name("input") + .type(eventInput)) + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [dateTimeScalar, eventInput]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "input field type reference is resolved" + def resolvedInput = fastSchema.getType("EventInput") as GraphQLInputObjectType + resolvedInput.getField("startDate").getType() == dateTimeScalar + } + + def "input object with nested input object type reference resolves correctly"() { + given: "SDL with nested input objects" + def sdl = """ + type Query { + createUser(input: UserInput): String + } + + input UserInput { + name: String + address: AddressInput + } + + input AddressInput { + street: String + city: String + } + """ + + and: "programmatically created types with nested type reference" + def addressInput = newInputObject() + .name("AddressInput") + .field(newInputObjectField() + .name("street") + .type(GraphQLString)) + .field(newInputObjectField() + .name("city") + .type(GraphQLString)) + .build() + + def userInput = newInputObject() + .name("UserInput") + .field(newInputObjectField() + .name("name") + .type(GraphQLString)) + .field(newInputObjectField() + .name("address") + .type(typeRef("AddressInput"))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("createUser") + .argument(newArgument() + .name("input") + .type(userInput)) + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [addressInput, userInput]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "nested input field type reference is resolved" + def resolvedUser = fastSchema.getType("UserInput") as GraphQLInputObjectType + resolvedUser.getField("address").getType() == addressInput + } + + // ==================== Directive Arguments with Type References ==================== + + def "directive argument with type reference resolves correctly"() { + given: "SDL with directive referencing custom scalar" + def sdl = """ + type Query { + value: String + } + + directive @bar(arg: Foo) on FIELD + + scalar Foo + """ + + and: "programmatically created types with type reference in directive" + def customScalar = newScalar() + .name("Foo") + .coercing(GraphQLString.getCoercing()) + .build() + + def directive = newDirective() + .name("bar") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("arg") + .type(typeRef("Foo"))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [customScalar], [directive]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "directive argument type reference is resolved" + def resolvedDirective = fastSchema.getDirective("bar") + resolvedDirective != null + resolvedDirective.getArgument("arg").getType() == customScalar + } + + def "directive argument with enum type reference resolves correctly"() { + given: "SDL with directive referencing enum" + def sdl = """ + type Query { + value: String + } + + directive @log(level: LogLevel) on FIELD + + enum LogLevel { + DEBUG + INFO + WARN + ERROR + } + """ + + and: "programmatically created types with enum type reference in directive" + def levelEnum = newEnum() + .name("LogLevel") + .value("DEBUG") + .value("INFO") + .value("WARN") + .value("ERROR") + .build() + + def directive = newDirective() + .name("log") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("level") + .type(typeRef("LogLevel"))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [levelEnum], [directive]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "directive argument enum type reference is resolved" + def resolvedDirective = fastSchema.getDirective("log") + resolvedDirective.getArgument("level").getType() == levelEnum + } + + def "directive argument with input object type reference resolves correctly"() { + given: "SDL with directive referencing input type" + def sdl = """ + type Query { + value: String + } + + directive @config(settings: ConfigInput) on FIELD + + input ConfigInput { + enabled: Boolean + } + """ + + and: "programmatically created types with input type reference in directive" + def configInput = newInputObject() + .name("ConfigInput") + .field(newInputObjectField() + .name("enabled") + .type(Scalars.GraphQLBoolean)) + .build() + + def directive = newDirective() + .name("config") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("settings") + .type(typeRef("ConfigInput"))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [configInput], [directive]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "directive argument input type reference is resolved" + def resolvedDirective = fastSchema.getDirective("config") + resolvedDirective.getArgument("settings").getType() == configInput + } + + // ==================== Applied Directives with Type References ==================== + + def "schema applied directive with type reference argument resolves correctly"() { + given: "SDL with schema-level applied directive" + def sdl = """ + directive @config(value: String) on SCHEMA + + type Query { + value: String + } + + schema @config(value: "test") { + query: Query + } + """ + + and: "programmatically created types with type reference in applied directive" + def directive = newDirective() + .name("config") + .validLocation(Introspection.DirectiveLocation.SCHEMA) + .argument(newArgument() + .name("value") + .type(GraphQLString)) + .build() + + def appliedDirective = newAppliedDirective() + .name("config") + .argument(newAppliedArgument() + .name("value") + .type(GraphQLString) + .valueProgrammatic("test")) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def builder = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalDirective(directive) + .withSchemaAppliedDirective(appliedDirective) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = builder.build() + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "applied directive argument type is String" + def resolved = fastSchema.getSchemaAppliedDirective("config") + resolved.getArgument("value").getType() == GraphQLString + } + + def "type applied directive with type reference argument resolves correctly"() { + given: "SDL with enum having applied directive" + def sdl = """ + directive @myDir(arg: String) on ENUM + + type Query { + status: Status + } + + enum Status @myDir(arg: "value") { + ACTIVE + } + """ + + and: "programmatically created types with type reference in applied directive" + def directive = newDirective() + .name("myDir") + .validLocation(Introspection.DirectiveLocation.ENUM) + .argument(newArgument() + .name("arg") + .type(GraphQLString)) + .build() + + def appliedDirective = newAppliedDirective() + .name("myDir") + .argument(newAppliedArgument() + .name("arg") + .type(GraphQLString) + .valueProgrammatic("value")) + .build() + + def enumType = newEnum() + .name("Status") + .value("ACTIVE") + .withAppliedDirective(appliedDirective) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("status") + .type(enumType)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [enumType], [directive]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "applied directive on type has resolved type" + def resolvedEnum = fastSchema.getType("Status") as GraphQLEnumType + def resolvedApplied = resolvedEnum.getAppliedDirective("myDir") + resolvedApplied.getArgument("arg").getType() == GraphQLString + } + + def "field applied directive with type reference argument resolves correctly"() { + given: "SDL with field having applied directive" + def sdl = """ + directive @fieldMeta(info: String) on FIELD_DEFINITION + + type Query { + value: String @fieldMeta(info: "metadata") + } + """ + + and: "programmatically created types with type reference in field applied directive" + def directive = newDirective() + .name("fieldMeta") + .validLocation(Introspection.DirectiveLocation.FIELD_DEFINITION) + .argument(newArgument() + .name("info") + .type(GraphQLString)) + .build() + + def appliedDirective = newAppliedDirective() + .name("fieldMeta") + .argument(newAppliedArgument() + .name("info") + .type(GraphQLString) + .valueProgrammatic("metadata")) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString) + .withAppliedDirective(appliedDirective)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [], [directive]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "applied directive on field has resolved type" + def field = fastSchema.queryType.getFieldDefinition("value") + def resolvedApplied = field.getAppliedDirective("fieldMeta") + resolvedApplied.getArgument("info").getType() == GraphQLString + } + + // ==================== Nested Type References ==================== + + def "nested type references with NonNull and List resolve correctly"() { + given: "SDL with deeply nested type wrappers" + def sdl = """ + type Query { + outer: Outer + } + + type Outer { + inner: [Inner!]! + } + + type Inner { + value: String + } + """ + + and: "programmatically created types with deeply nested type reference" + def innerType = newObject() + .name("Inner") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def outerType = newObject() + .name("Outer") + .field(newFieldDefinition() + .name("inner") + .type(GraphQLNonNull.nonNull(GraphQLList.list(GraphQLNonNull.nonNull(typeRef("Inner")))))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("outer") + .type(outerType)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [innerType, outerType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "deeply nested type reference is resolved" + def resolvedOuter = fastSchema.getType("Outer") as GraphQLObjectType + def fieldType = resolvedOuter.getFieldDefinition("inner").getType() + fieldType instanceof GraphQLNonNull + def listType = ((GraphQLNonNull) fieldType).getWrappedType() + listType instanceof GraphQLList + def itemType = ((GraphQLList) listType).getWrappedType() + itemType instanceof GraphQLNonNull + ((GraphQLNonNull) itemType).getWrappedType() == innerType + } + + def "circular type reference resolves correctly"() { + given: "SDL with self-referencing type" + def sdl = """ + type Query { + person: Person + } + + type Person { + name: String + friend: Person + } + """ + + and: "programmatically created types with circular reference" + def personType = newObject() + .name("Person") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("friend") + .type(typeRef("Person"))) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("person") + .type(personType)) + .build() + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [personType]) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "circular reference is resolved" + def resolvedPerson = fastSchema.getType("Person") as GraphQLObjectType + resolvedPerson.getFieldDefinition("friend").getType() == personType + } + + // ==================== Complex Schema with Multiple Type References ==================== + + def "complex schema with multiple type references resolves correctly"() { + given: "SDL with many interconnected types" + def sdl = """ + type Query { + node(id: String): Node + search(filter: FilterInput): [SearchResult] + } + + interface Node { + id: String + } + + input FilterInput { + active: Boolean + } + + type User implements Node { + id: String + name: String + } + + type Post implements Node { + id: String + title: String + author: User + } + + union SearchResult = User | Post + """ + + and: "programmatically created types with multiple type references" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def filterInput = newInputObject() + .name("FilterInput") + .field(newInputObjectField() + .name("active") + .type(Scalars.GraphQLBoolean)) + .build() + + def userType = newObject() + .name("User") + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + def postType = newObject() + .name("Post") + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("title") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("author") + .type(typeRef("User"))) + .build() + + def searchResultUnion = GraphQLUnionType.newUnionType() + .name("SearchResult") + .possibleType(typeRef("User")) + .possibleType(typeRef("Post")) + .build() + + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .argument(newArgument() + .name("id") + .type(GraphQLString)) + .type(nodeInterface)) + .field(newFieldDefinition() + .name("search") + .argument(newArgument() + .name("filter") + .type(typeRef("FilterInput"))) + .type(GraphQLList.list(typeRef("SearchResult")))) + .build() + + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + .typeResolver("SearchResult", { env -> null }) + + when: "building with both approaches" + def standardSchema = buildSchemaFromSDL(sdl) + def fastSchema = buildSchemaWithFastBuilder( + queryType, null, null, + [nodeInterface, filterInput, userType, postType, searchResultUnion], + [], codeRegistry) + + then: "schemas are equivalent" + assertSchemasEquivalent(fastSchema, standardSchema) + + and: "all type references are resolved" + // Interface implementations + def resolvedUser = fastSchema.getType("User") as GraphQLObjectType + resolvedUser.getInterfaces()[0] == nodeInterface + + // Object field references + def resolvedPost = fastSchema.getType("Post") as GraphQLObjectType + resolvedPost.getFieldDefinition("author").getType() == userType + + // Union members + def resolvedUnion = fastSchema.getType("SearchResult") as GraphQLUnionType + resolvedUnion.types.any { it.name == "User" } + resolvedUnion.types.any { it.name == "Post" } + + // Field arguments + def searchField = fastSchema.queryType.getFieldDefinition("search") + searchField.getArgument("filter").getType() == filterInput + def searchReturnType = searchField.getType() as GraphQLList + searchReturnType.getWrappedType() == searchResultUnion + } +} diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index afd3ab4c9..be794fedb 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -20,45 +20,6 @@ import static graphql.schema.GraphQLTypeReference.typeRef class FastBuilderTest extends Specification { - def "scalar type schema matches standard builder"() { - given: "a custom scalar type" - def customScalar = newScalar() - .name("CustomScalar") - .coercing(GraphQLString.getCoercing()) - .build() - - and: "a query type using the scalar" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(customScalar)) - .build() - - and: "code registry" - def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() - - when: "building with FastBuilder" - def fastSchema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(customScalar) - .build() - - and: "building with standard Builder" - def standardSchema = GraphQLSchema.newSchema() - .query(queryType) - .codeRegistry(codeRegistry.build()) - .additionalType(customScalar) - .build() - - then: "schemas are equivalent" - fastSchema.queryType.name == standardSchema.queryType.name - fastSchema.getType("CustomScalar") != null - fastSchema.getType("CustomScalar").name == standardSchema.getType("CustomScalar").name - // Check that the types in both schemas match (excluding system types added differently) - fastSchema.typeMap.keySet().containsAll(["Query", "CustomScalar"]) - standardSchema.typeMap.keySet().containsAll(["Query", "CustomScalar"]) - } - def "duplicate type with different instance throws error"() { given: "two different scalar instances with same name" def scalar1 = newScalar() @@ -140,31 +101,6 @@ class FastBuilderTest extends Specification { schema.queryType.name == "Query" } - def "built-in directives are added automatically"() { - given: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "code registry" - def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() - - when: "building schema" - def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .build() - - then: "built-in directives are present" - schema.getDirective("skip") != null - schema.getDirective("include") != null - schema.getDirective("deprecated") != null - schema.getDirective("specifiedBy") != null - schema.getDirective("oneOf") != null - schema.getDirective("defer") != null - } - def "query type is required"() { when: "creating FastBuilder with null query type" new GraphQLSchema.FastBuilder(GraphQLCodeRegistry.newCodeRegistry(), null, null, null) @@ -189,65 +125,6 @@ class FastBuilderTest extends Specification { thrown(AssertException) } - def "mutation and subscription types are optional"() { - given: "query, mutation, and subscription types" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def mutationType = newObject() - .name("Mutation") - .field(newFieldDefinition() - .name("setValue") - .type(GraphQLString)) - .build() - - def subscriptionType = newObject() - .name("Subscription") - .field(newFieldDefinition() - .name("valueChanged") - .type(GraphQLString)) - .build() - - and: "code registry" - def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() - - when: "building schema with all root types" - def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, mutationType, subscriptionType) - .build() - - then: "all root types are present" - schema.queryType.name == "Query" - schema.mutationType.name == "Mutation" - schema.subscriptionType.name == "Subscription" - schema.isSupportingMutations() - schema.isSupportingSubscriptions() - } - - def "schema description can be set"() { - given: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "code registry" - def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() - - when: "building schema with description" - def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .description("Test schema description") - .build() - - then: "description is set" - schema.description == "Test schema description" - } - def "additionalTypes accepts collection"() { given: "multiple scalar types" def scalar1 = newScalar() @@ -572,45 +449,6 @@ class FastBuilderTest extends Specification { (resolvedEnum as GraphQLEnumType).getValue("PENDING") != null } - def "enum type matches standard builder"() { - given: "an enum type" - def statusEnum = newEnum() - .name("Status") - .value("ACTIVE") - .value("INACTIVE") - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("status") - .type(statusEnum)) - .build() - - and: "code registry" - def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() - - when: "building with FastBuilder" - def fastSchema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(statusEnum) - .build() - - and: "building with standard Builder" - def standardSchema = GraphQLSchema.newSchema() - .query(queryType) - .codeRegistry(codeRegistry.build()) - .additionalType(statusEnum) - .build() - - then: "schemas have equivalent enum types" - def fastEnum = fastSchema.getType("Status") as GraphQLEnumType - def standardEnum = standardSchema.getType("Status") as GraphQLEnumType - fastEnum.values.size() == standardEnum.values.size() - fastEnum.getValue("ACTIVE") != null - fastEnum.getValue("INACTIVE") != null - } - def "directive argument with enum type reference resolves correctly"() { given: "an enum type" def levelEnum = newEnum() @@ -1922,4 +1760,289 @@ class FastBuilderTest extends Specification { def resolvedApplied = resolvedUnion.getAppliedDirective("unionMeta") resolvedApplied.getArgument("info").getType() == metaScalar } + + // ==================== Phase 9: Validation and Edge Cases ==================== + + def "withValidation(false) allows schema without type resolver"() { + given: "an interface without type resolver" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + // No type resolver! + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .build() + + when: "building with validation disabled" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(nodeInterface) + .withValidation(false) + .build() + + then: "schema builds without error" + schema != null + schema.getType("Node") instanceof GraphQLInterfaceType + } + + def "withValidation(true) rejects schema with incomplete interface implementation"() { + given: "an interface with id field" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "an object claiming to implement interface but missing id field" + def badImplementor = newObject() + .name("BadImplementor") + .withInterface(nodeInterface) + // Missing id field! + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .build() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + + when: "building with validation enabled" + new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(nodeInterface) + .additionalType(badImplementor) + .withValidation(true) + .build() + + then: "validation error is thrown" + thrown(graphql.schema.validation.InvalidSchemaException) + } + + def "circular type reference resolves correctly"() { + given: "types with circular reference" + def personType = newObject() + .name("Person") + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("friend") + .type(typeRef("Person"))) // Self-reference + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("person") + .type(personType)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(personType) + .build() + + then: "circular reference is resolved" + def resolvedPerson = schema.getType("Person") as GraphQLObjectType + resolvedPerson.getFieldDefinition("friend").getType() == personType + } + + def "deeply nested type reference resolves correctly"() { + given: "deeply nested types" + def innerType = newObject() + .name("Inner") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "type with deeply nested reference" + def outerType = newObject() + .name("Outer") + .field(newFieldDefinition() + .name("inner") + .type(GraphQLNonNull.nonNull(GraphQLList.list(GraphQLNonNull.nonNull(typeRef("Inner")))))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("outer") + .type(outerType)) + .build() + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(innerType) + .additionalType(outerType) + .build() + + then: "deeply nested type reference is resolved" + def resolvedOuter = schema.getType("Outer") as GraphQLObjectType + def fieldType = resolvedOuter.getFieldDefinition("inner").getType() + fieldType instanceof GraphQLNonNull + def listType = ((GraphQLNonNull) fieldType).getWrappedType() + listType instanceof GraphQLList + def itemType = ((GraphQLList) listType).getWrappedType() + itemType instanceof GraphQLNonNull + ((GraphQLNonNull) itemType).getWrappedType() == innerType + } + + def "complex schema builds correctly"() { + given: "a complex set of types" + // Interface + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + // Input type + def filterInput = newInputObject() + .name("FilterInput") + .field(newInputObjectField() + .name("active") + .type(Scalars.GraphQLBoolean)) + .build() + + // Object implementing interface + def userType = newObject() + .name("User") + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + // Another object implementing interface + def postType = newObject() + .name("Post") + .withInterface(typeRef("Node")) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("title") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("author") + .type(typeRef("User"))) + .build() + + // Union + def searchResultUnion = GraphQLUnionType.newUnionType() + .name("SearchResult") + .possibleType(typeRef("User")) + .possibleType(typeRef("Post")) + .build() + + // Query type + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .argument(newArgument() + .name("id") + .type(GraphQLString)) + .type(nodeInterface)) + .field(newFieldDefinition() + .name("search") + .argument(newArgument() + .name("filter") + .type(typeRef("FilterInput"))) + .type(GraphQLList.list(typeRef("SearchResult")))) + .build() + + // Code registry with type resolvers + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + .typeResolver("SearchResult", { env -> null }) + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) + .additionalType(nodeInterface) + .additionalType(filterInput) + .additionalType(userType) + .additionalType(postType) + .additionalType(searchResultUnion) + .build() + + then: "all types are resolved correctly" + schema.getType("Node") instanceof GraphQLInterfaceType + schema.getType("FilterInput") instanceof GraphQLInputObjectType + schema.getType("User") instanceof GraphQLObjectType + schema.getType("Post") instanceof GraphQLObjectType + schema.getType("SearchResult") instanceof GraphQLUnionType + + and: "interface implementations are tracked" + schema.getImplementations(nodeInterface as GraphQLInterfaceType).size() == 2 + schema.getImplementations(nodeInterface as GraphQLInterfaceType).any { it.name == "User" } + schema.getImplementations(nodeInterface as GraphQLInterfaceType).any { it.name == "Post" } + + and: "type references are resolved" + def resolvedUser = schema.getType("User") as GraphQLObjectType + resolvedUser.getInterfaces()[0] == nodeInterface + + def resolvedPost = schema.getType("Post") as GraphQLObjectType + resolvedPost.getFieldDefinition("author").getType() == userType + + def resolvedUnion = schema.getType("SearchResult") as GraphQLUnionType + resolvedUnion.types.any { it.name == "User" } + resolvedUnion.types.any { it.name == "Post" } + + def searchField = schema.queryType.getFieldDefinition("search") + searchField.getArgument("filter").getType() == filterInput + } + + def "null types and directives are ignored"() { + given: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "adding null types and directives" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .additionalType(null) + .additionalTypes(null) + .additionalDirective(null) + .additionalDirectives(null) + .withSchemaDirective(null) + .withSchemaDirectives(null) + .withSchemaAppliedDirective(null) + .withSchemaAppliedDirectives(null) + .build() + + then: "no error and schema builds" + schema != null + schema.queryType.name == "Query" + } } diff --git a/src/test/groovy/graphql/schema/impl/FindDetachedTypesTest.groovy b/src/test/groovy/graphql/schema/impl/FindDetachedTypesTest.groovy new file mode 100644 index 000000000..ac66b7a10 --- /dev/null +++ b/src/test/groovy/graphql/schema/impl/FindDetachedTypesTest.groovy @@ -0,0 +1,1017 @@ +package graphql.schema.impl + +import graphql.Scalars +import graphql.introspection.Introspection +import graphql.schema.GraphQLDirective +import graphql.schema.GraphQLNamedType +import graphql.schema.GraphQLObjectType +import spock.lang.Specification + +import static graphql.Scalars.GraphQLInt +import static graphql.Scalars.GraphQLString +import static graphql.schema.GraphQLArgument.newArgument +import static graphql.schema.GraphQLDirective.newDirective +import static graphql.schema.GraphQLEnumType.newEnum +import static graphql.schema.GraphQLEnumValueDefinition.newEnumValueDefinition +import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition +import static graphql.schema.GraphQLInputObjectField.newInputObjectField +import static graphql.schema.GraphQLInputObjectType.newInputObject +import static graphql.schema.GraphQLInterfaceType.newInterface +import static graphql.schema.GraphQLList.list +import static graphql.schema.GraphQLNonNull.nonNull +import static graphql.schema.GraphQLObjectType.newObject +import static graphql.schema.GraphQLScalarType.newScalar +import static graphql.schema.GraphQLUnionType.newUnionType + +class FindDetachedTypesTest extends Specification { + + def "type not reachable from any root is detached"() { + given: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "a detached type" + def detachedType = newObject() + .name("DetachedType") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "type map including both (built-in scalars are attached via Query field)" + def typeMap = [ + "Query" : queryType, + "DetachedType": detachedType, + "String" : GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "only DetachedType is detached" + detached*.name as Set == ["DetachedType"] as Set + } + + def "type reachable from Query is attached"() { + given: "a custom type" + def customType = newObject() + .name("CustomType") + .field(newFieldDefinition() + .name("id") + .type(GraphQLInt)) + .build() + + and: "a query type that references the custom type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("custom") + .type(customType)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "CustomType": customType, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "custom type is attached (not detached)" + detached.empty + } + + def "type reachable from Mutation is attached"() { + given: "a custom type" + def customType = newObject() + .name("CustomType") + .field(newFieldDefinition() + .name("id") + .type(GraphQLInt)) + .build() + + and: "a mutation type that references the custom type" + def mutationType = newObject() + .name("Mutation") + .field(newFieldDefinition() + .name("createCustom") + .type(customType)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "Mutation" : mutationType, + "CustomType": customType, + "String" : GraphQLString, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, mutationType, null, []) + + then: "custom type is attached (not detached)" + detached.empty + } + + def "type reachable from Subscription is attached"() { + given: "a custom type" + def customType = newObject() + .name("CustomType") + .field(newFieldDefinition() + .name("id") + .type(GraphQLInt)) + .build() + + and: "a subscription type that references the custom type" + def subscriptionType = newObject() + .name("Subscription") + .field(newFieldDefinition() + .name("customChanged") + .type(customType)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "Subscription": subscriptionType, + "CustomType" : customType, + "String" : GraphQLString, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, subscriptionType, []) + + then: "custom type is attached (not detached)" + detached.empty + } + + def "root types themselves are attached"() { + given: "query, mutation, and subscription types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def mutationType = newObject() + .name("Mutation") + .field(newFieldDefinition() + .name("setValue") + .type(GraphQLString)) + .build() + + def subscriptionType = newObject() + .name("Subscription") + .field(newFieldDefinition() + .name("valueChanged") + .type(GraphQLString)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "Mutation" : mutationType, + "Subscription": subscriptionType, + "String" : GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, mutationType, subscriptionType, []) + + then: "no root types are detached" + detached.empty + } + + def "object type field types are followed"() { + given: "a nested type" + def nestedType = newObject() + .name("NestedType") + .field(newFieldDefinition() + .name("value") + .type(GraphQLInt)) + .build() + + and: "an object type with field of nested type" + def parentType = newObject() + .name("ParentType") + .field(newFieldDefinition() + .name("nested") + .type(nestedType)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("parent") + .type(parentType)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "ParentType": parentType, + "NestedType": nestedType, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "nested type is attached (not detached)" + detached.empty + } + + def "object type field argument types are followed"() { + given: "an input type" + def inputType = newInputObject() + .name("InputType") + .field(newInputObjectField() + .name("value") + .type(GraphQLInt)) + .build() + + and: "a query type with field that has argument of input type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("search") + .type(GraphQLString) + .argument(newArgument() + .name("filter") + .type(inputType))) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "InputType": inputType, + "String" : GraphQLString, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "input type is attached (not detached)" + detached.empty + } + + def "object type interface implementations are followed"() { + given: "an interface type" + def interfaceType = newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "an object type implementing the interface" + def objectType = newObject() + .name("User") + .withInterface(interfaceType) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("user") + .type(objectType)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "User" : objectType, + "Node" : interfaceType, + "String": GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "interface type is attached (not detached)" + detached.empty + } + + def "interface type field types are followed"() { + given: "a custom type" + def customType = newObject() + .name("CustomType") + .field(newFieldDefinition() + .name("value") + .type(GraphQLInt)) + .build() + + and: "an interface type with field of custom type" + def interfaceType = newInterface() + .name("Node") + .field(newFieldDefinition() + .name("custom") + .type(customType)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(interfaceType)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "Node" : interfaceType, + "CustomType": customType, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "custom type is attached (not detached)" + detached.empty + } + + def "interface type field argument types are followed"() { + given: "an input type" + def inputType = newInputObject() + .name("InputType") + .field(newInputObjectField() + .name("value") + .type(GraphQLInt)) + .build() + + and: "an interface type with field that has argument of input type" + def interfaceType = newInterface() + .name("Searchable") + .field(newFieldDefinition() + .name("search") + .type(GraphQLString) + .argument(newArgument() + .name("filter") + .type(inputType))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("searchable") + .type(interfaceType)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "Searchable": interfaceType, + "InputType" : inputType, + "String" : GraphQLString, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "input type is attached (not detached)" + detached.empty + } + + def "interface extending interface is followed"() { + given: "a base interface" + def baseInterface = newInterface() + .name("BaseInterface") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "an interface extending the base" + def childInterface = newInterface() + .name("ChildInterface") + .withInterface(baseInterface) + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .field(newFieldDefinition() + .name("name") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("child") + .type(childInterface)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "ChildInterface": childInterface, + "BaseInterface" : baseInterface, + "String" : GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "base interface is attached (not detached)" + detached.empty + } + + def "union member types are followed"() { + given: "union member types" + def typeA = newObject() + .name("TypeA") + .field(newFieldDefinition() + .name("a") + .type(GraphQLString)) + .build() + + def typeB = newObject() + .name("TypeB") + .field(newFieldDefinition() + .name("b") + .type(GraphQLInt)) + .build() + + and: "a union type" + def unionType = newUnionType() + .name("SearchResult") + .possibleType(typeA) + .possibleType(typeB) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("search") + .type(unionType)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "SearchResult": unionType, + "TypeA" : typeA, + "TypeB" : typeB, + "String" : GraphQLString, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "all union member types are attached (not detached)" + detached.empty + } + + def "input object field types are followed"() { + given: "a nested input type" + def nestedInput = newInputObject() + .name("NestedInput") + .field(newInputObjectField() + .name("value") + .type(GraphQLInt)) + .build() + + and: "an input type with field of nested input type" + def inputType = newInputObject() + .name("InputType") + .field(newInputObjectField() + .name("nested") + .type(nestedInput)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("search") + .type(GraphQLString) + .argument(newArgument() + .name("input") + .type(inputType))) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "InputType" : inputType, + "NestedInput": nestedInput, + "String" : GraphQLString, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "nested input type is attached (not detached)" + detached.empty + } + + def "wrapped types (NonNull, List) are properly unwrapped"() { + given: "a custom type" + def customType = newObject() + .name("CustomType") + .field(newFieldDefinition() + .name("id") + .type(GraphQLInt)) + .build() + + and: "a query type with wrapped field type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("items") + .type(nonNull(list(nonNull(customType))))) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "CustomType": customType, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "custom type is attached despite wrapping (not detached)" + detached.empty + } + + def "type used only in directive argument is attached"() { + given: "a custom scalar" + def customScalar = newScalar() + .name("CustomScalar") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive using the custom scalar" + def directive = newDirective() + .name("customDirective") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("value") + .type(customScalar)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "CustomScalar": customScalar, + "String" : GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, [directive]) + + then: "custom scalar is attached (not detached)" + detached.empty + } + + def "directive with multiple arguments attaches all argument types"() { + given: "custom input types" + def inputTypeA = newInputObject() + .name("InputTypeA") + .field(newInputObjectField() + .name("value") + .type(GraphQLString)) + .build() + + def inputTypeB = newInputObject() + .name("InputTypeB") + .field(newInputObjectField() + .name("value") + .type(GraphQLString)) + .build() + + and: "a directive using both input types" + def directive = newDirective() + .name("multiArgDirective") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("argA") + .type(inputTypeA)) + .argument(newArgument() + .name("argB") + .type(inputTypeB)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "InputTypeA": inputTypeA, + "InputTypeB": inputTypeB, + "String" : GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, [directive]) + + then: "all directive argument types are attached (not detached)" + detached.empty + } + + def "empty schema has no detached types"() { + given: "a minimal query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "type map with only Query and String" + def typeMap = [ + "Query" : queryType, + "String": GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "no detached types" + detached.empty + } + + def "all types attached returns empty set"() { + given: "multiple types all connected" + def typeA = newObject() + .name("TypeA") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def typeB = newObject() + .name("TypeB") + .field(newFieldDefinition() + .name("value") + .type(GraphQLInt)) + .build() + + and: "a query type referencing both" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("a") + .type(typeA)) + .field(newFieldDefinition() + .name("b") + .type(typeB)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "TypeA" : typeA, + "TypeB" : typeB, + "String": GraphQLString, + "Int" : GraphQLInt + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "no detached types" + detached.empty + } + + def "all types detached except roots returns correct set"() { + given: "multiple detached types" + def detachedA = newObject() + .name("DetachedA") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def detachedB = newObject() + .name("DetachedB") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def detachedC = newObject() + .name("DetachedC") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "DetachedA": detachedA, + "DetachedB": detachedB, + "DetachedC": detachedC, + "String" : GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "all non-root types are detached" + detached*.name as Set == ["DetachedA", "DetachedB", "DetachedC"] as Set + } + + def "circular type references don't cause infinite loop"() { + given: "two types that reference each other" + def typeA = newObject() + .name("TypeA") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + def typeB = newObject() + .name("TypeB") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + // Add circular references + typeA = typeA.transform { builder -> + builder.field(newFieldDefinition() + .name("b") + .type(typeB)) + } + + typeB = typeB.transform { builder -> + builder.field(newFieldDefinition() + .name("a") + .type(typeA)) + } + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("a") + .type(typeA)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "TypeA" : typeA, + "TypeB" : typeB, + "String": GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "no error and both types are attached" + detached.empty + } + + def "complex mixed scenario with attached and detached types"() { + given: "attached types" + def attachedObject = newObject() + .name("AttachedObject") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def attachedEnum = newEnum() + .name("AttachedEnum") + .value(newEnumValueDefinition().name("VALUE1").value("VALUE1").build()) + .build() + + and: "detached types" + def detachedObject = newObject() + .name("DetachedObject") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def detachedScalar = newScalar() + .name("DetachedScalar") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a query type referencing only attached types" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("object") + .type(attachedObject)) + .field(newFieldDefinition() + .name("enum") + .type(attachedEnum)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "AttachedObject" : attachedObject, + "AttachedEnum" : attachedEnum, + "DetachedObject" : detachedObject, + "DetachedScalar" : detachedScalar, + "String" : GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "only detached types are returned" + detached*.name as Set == ["DetachedObject", "DetachedScalar"] as Set + } + + def "type reachable through multiple paths is still attached"() { + given: "a shared type" + def sharedType = newObject() + .name("SharedType") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "a query type with multiple paths to shared type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("path1") + .type(sharedType)) + .field(newFieldDefinition() + .name("path2") + .type(sharedType)) + .field(newFieldDefinition() + .name("path3") + .type(list(sharedType))) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "SharedType": sharedType, + "String" : GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "shared type is attached (visited once, not multiple times)" + detached.empty + } + + def "type reachable from directive but not roots is still attached"() { + given: "a type used only in directive" + def directiveOnlyType = newInputObject() + .name("DirectiveOnlyType") + .field(newInputObjectField() + .name("value") + .type(GraphQLString)) + .build() + + and: "a directive using this type" + def directive = newDirective() + .name("customDirective") + .validLocation(Introspection.DirectiveLocation.FIELD) + .argument(newArgument() + .name("config") + .type(directiveOnlyType)) + .build() + + and: "a detached type" + def detachedType = newObject() + .name("DetachedType") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "DirectiveOnlyType": directiveOnlyType, + "DetachedType" : detachedType, + "String" : GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, [directive]) + + then: "directive type is attached, but truly detached type is not" + detached*.name as Set == ["DetachedType"] as Set + } + + def "deep nesting with wrapped types is properly traversed"() { + given: "deeply nested types" + def level3Type = newObject() + .name("Level3") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + def level2Type = newObject() + .name("Level2") + .field(newFieldDefinition() + .name("level3") + .type(nonNull(list(level3Type)))) + .build() + + def level1Type = newObject() + .name("Level1") + .field(newFieldDefinition() + .name("level2") + .type(list(nonNull(level2Type)))) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("level1") + .type(nonNull(level1Type))) + .build() + + and: "type map" + def typeMap = [ + "Query" : queryType, + "Level1": level1Type, + "Level2": level2Type, + "Level3": level3Type, + "String": GraphQLString + ] + + when: "finding detached types" + def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) + + then: "all nested types are attached" + detached.empty + } +} From b7e9e46083c6eb81c7e52411ab8d3a3dc7c3a73a Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sat, 20 Dec 2025 13:21:46 +0000 Subject: [PATCH 11/25] Add FastSchemaGenerator and JMH benchmark infrastructure Add BuildSchemaBenchmark to measure schema construction performance in isolation from SDL parsing. This benchmark compares standard SchemaGenerator against FastSchemaGenerator using the large-schema-4.graphqls test schema (~18,800 types). Changes: - Add FastSchemaGenerator that uses FastBuilder for optimized schema construction from pre-parsed TypeDefinitionRegistry - Add BuildSchemaBenchmark.java with side-by-side comparison of standard vs fast schema generation, parsing SDL once in @Setup to isolate build performance - Add jmhProfilers gradle property support for GC profiling (e.g., -PjmhProfilers="gc") - Add FastSchemaGeneratorTest to verify equivalence with standard SchemaGenerator Usage: ./gradlew jmh -PjmhInclude=".*BuildSchemaBenchmark.*" ./gradlew jmh -PjmhInclude=".*BuildSchemaBenchmark.*" -PjmhProfilers="gc" --- build.gradle | 9 ++ .../java/benchmark/BuildSchemaBenchmark.java | 52 ++++++ .../java/benchmark/CreateSchemaBenchmark.java | 17 +- .../schema/idl/FastSchemaGenerator.java | 149 ++++++++++++++++++ .../schema/idl/SchemaGeneratorHelper.java | 11 ++ .../schema/idl/FastSchemaGeneratorTest.groovy | 137 ++++++++++++++++ 6 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 src/jmh/java/benchmark/BuildSchemaBenchmark.java create mode 100644 src/main/java/graphql/schema/idl/FastSchemaGenerator.java create mode 100644 src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy diff --git a/build.gradle b/build.gradle index fd9a2d097..aed4161de 100644 --- a/build.gradle +++ b/build.gradle @@ -223,6 +223,15 @@ tasks.named('jmhJar') { } } +jmh { + if (project.hasProperty('jmhInclude')) { + includes = [project.property('jmhInclude')] + } + if (project.hasProperty('jmhProfilers')) { + profilers = [project.property('jmhProfilers')] + } +} + task extractWithoutGuava(type: Copy) { from({ zipTree({ "build/libs/graphql-java-${project.version}.jar" }) }) { diff --git a/src/jmh/java/benchmark/BuildSchemaBenchmark.java b/src/jmh/java/benchmark/BuildSchemaBenchmark.java new file mode 100644 index 000000000..de12731e6 --- /dev/null +++ b/src/jmh/java/benchmark/BuildSchemaBenchmark.java @@ -0,0 +1,52 @@ +package benchmark; + +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.FastSchemaGenerator; +import graphql.schema.idl.RuntimeWiring; +import graphql.schema.idl.SchemaGenerator; +import graphql.schema.idl.SchemaParser; +import graphql.schema.idl.TypeDefinitionRegistry; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +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 org.openjdk.jmh.infra.Blackhole; + +import java.util.concurrent.TimeUnit; + +@Warmup(iterations = 2, time = 5) +@Measurement(iterations = 3) +@Fork(2) +@State(Scope.Benchmark) +public class BuildSchemaBenchmark { + + static String largeSDL = BenchmarkUtils.loadResource("large-schema-4.graphqls"); + + private TypeDefinitionRegistry registry; + + @Setup + public void setup() { + // Parse SDL once before benchmarks run + registry = new SchemaParser().parse(largeSDL); + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MILLISECONDS) + public void benchmarkBuildSchemaAvgTime(Blackhole blackhole) { + blackhole.consume(new SchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING)); + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MILLISECONDS) + public void benchmarkBuildSchemaAvgTimeFast(Blackhole blackhole) { + blackhole.consume(new FastSchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING)); + } +} diff --git a/src/jmh/java/benchmark/CreateSchemaBenchmark.java b/src/jmh/java/benchmark/CreateSchemaBenchmark.java index 6d2099041..ddbbd6fda 100644 --- a/src/jmh/java/benchmark/CreateSchemaBenchmark.java +++ b/src/jmh/java/benchmark/CreateSchemaBenchmark.java @@ -1,6 +1,7 @@ package benchmark; import graphql.schema.GraphQLSchema; +import graphql.schema.idl.FastSchemaGenerator; import graphql.schema.idl.RuntimeWiring; import graphql.schema.idl.SchemaGenerator; import graphql.schema.idl.SchemaParser; @@ -21,7 +22,7 @@ @Fork(2) public class CreateSchemaBenchmark { - static String largeSDL = BenchmarkUtils.loadResource("large-schema-3.graphqls"); + static String largeSDL = BenchmarkUtils.loadResource("large-schema-4.graphqls"); @Benchmark @BenchmarkMode(Mode.Throughput) @@ -37,11 +38,23 @@ public void benchmarkLargeSchemaCreateAvgTime(Blackhole blackhole) { blackhole.consume(createSchema(largeSDL)); } + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.MILLISECONDS) + public void benchmarkLargeSchemaCreateAvgTimeFast(Blackhole blackhole) { + blackhole.consume(createSchemaFast(largeSDL)); + } + private static GraphQLSchema createSchema(String sdl) { TypeDefinitionRegistry registry = new SchemaParser().parse(sdl); return new SchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING); } + private static GraphQLSchema createSchemaFast(String sdl) { + TypeDefinitionRegistry registry = new SchemaParser().parse(sdl); + return new FastSchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING); + } + @SuppressWarnings("InfiniteLoopStatement") /// make this a main method if you want to run it in JProfiler etc.. public static void mainXXX(String[] args) { @@ -54,4 +67,4 @@ public static void mainXXX(String[] args) { } } } -} \ No newline at end of file +} diff --git a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java new file mode 100644 index 000000000..323624d6d --- /dev/null +++ b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java @@ -0,0 +1,149 @@ +package graphql.schema.idl; + +import graphql.Internal; +import graphql.language.OperationTypeDefinition; +import graphql.schema.GraphQLCodeRegistry; +import graphql.schema.GraphQLDirective; +import graphql.schema.GraphQLObjectType; +import graphql.schema.GraphQLSchema; +import graphql.schema.GraphQLType; + +import java.util.Map; +import java.util.Set; + +import static graphql.schema.idl.SchemaGeneratorHelper.buildDescription; + +/** + * A schema generator that uses GraphQLSchema.FastBuilder for improved performance. + * This is intended for benchmarking and performance testing purposes. + */ +@Internal +public class FastSchemaGenerator { + + private final SchemaGeneratorHelper schemaGeneratorHelper = new SchemaGeneratorHelper(); + + /** + * Creates an executable schema from a TypeDefinitionRegistry using FastBuilder. + * This method is optimized for performance and skips validation by default. + * + * @param typeRegistry the type definition registry + * @param wiring the runtime wiring + * @return an executable schema + */ + public GraphQLSchema makeExecutableSchema(TypeDefinitionRegistry typeRegistry, RuntimeWiring wiring) { + return makeExecutableSchema(SchemaGenerator.Options.defaultOptions(), typeRegistry, wiring); + } + + /** + * Creates an executable schema from a TypeDefinitionRegistry using FastBuilder. + * + * @param options the schema generation options + * @param typeRegistry the type definition registry + * @param wiring the runtime wiring + * @return an executable schema + */ + public GraphQLSchema makeExecutableSchema(SchemaGenerator.Options options, TypeDefinitionRegistry typeRegistry, RuntimeWiring wiring) { + // Make a copy and add default directives + TypeDefinitionRegistry typeRegistryCopy = new TypeDefinitionRegistry(); + typeRegistryCopy.merge(typeRegistry); + schemaGeneratorHelper.addDirectivesIncludedByDefault(typeRegistryCopy); + + // Use immutable registry for faster operations + ImmutableTypeDefinitionRegistry fasterImmutableRegistry = typeRegistryCopy.readOnly(); + + Map operationTypeDefinitions = SchemaExtensionsChecker.gatherOperationDefs(fasterImmutableRegistry); + + return makeExecutableSchemaImpl(fasterImmutableRegistry, wiring, operationTypeDefinitions, options); + } + + private GraphQLSchema makeExecutableSchemaImpl(ImmutableTypeDefinitionRegistry typeRegistry, + RuntimeWiring wiring, + Map operationTypeDefinitions, + SchemaGenerator.Options options) { + // Build all types using the standard helper + SchemaGeneratorHelper.BuildContext buildCtx = new SchemaGeneratorHelper.BuildContext( + typeRegistry, wiring, operationTypeDefinitions, options); + + // Build directives + Set additionalDirectives = schemaGeneratorHelper.buildAdditionalDirectiveDefinitions(buildCtx); + + // Use a dummy builder to trigger type building (this populates buildCtx) + GraphQLSchema.Builder tempBuilder = GraphQLSchema.newSchema(); + schemaGeneratorHelper.buildOperations(buildCtx, tempBuilder); + + // Build all additional types + Set additionalTypes = schemaGeneratorHelper.buildAdditionalTypes(buildCtx); + + // Set field visibility on code registry + buildCtx.getCodeRegistry().fieldVisibility(buildCtx.getWiring().getFieldVisibility()); + + // Build the code registry + GraphQLCodeRegistry codeRegistry = buildCtx.getCodeRegistry().build(); + + // Extract operation types by name from built types + Set allBuiltTypes = buildCtx.getTypes(); + + // Get the actual type names from operationTypeDefinitions, defaulting to standard names + String queryTypeName = getOperationTypeName(operationTypeDefinitions, "query", "Query"); + String mutationTypeName = getOperationTypeName(operationTypeDefinitions, "mutation", "Mutation"); + String subscriptionTypeName = getOperationTypeName(operationTypeDefinitions, "subscription", "Subscription"); + + GraphQLObjectType queryType = findOperationType(allBuiltTypes, queryTypeName); + GraphQLObjectType mutationType = findOperationType(allBuiltTypes, mutationTypeName); + GraphQLObjectType subscriptionType = findOperationType(allBuiltTypes, subscriptionTypeName); + + if (queryType == null) { + throw new IllegalStateException("Query type '" + queryTypeName + "' is required but was not found"); + } + + // Create FastBuilder + GraphQLSchema.FastBuilder fastBuilder = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(codeRegistry), + queryType, + mutationType, + subscriptionType); + + // Add all built types + fastBuilder.additionalTypes(allBuiltTypes); + fastBuilder.additionalTypes(additionalTypes); + + // Add all directive definitions + fastBuilder.additionalDirectives(additionalDirectives); + + // Add schema description if present + typeRegistry.schemaDefinition().ifPresent(schemaDefinition -> { + String description = buildDescription(buildCtx, schemaDefinition, schemaDefinition.getDescription()); + fastBuilder.description(description); + }); + + // Add schema definition + fastBuilder.definition(typeRegistry.schemaDefinition().orElse(null)); + + // Disable validation for performance + fastBuilder.withValidation(false); + + return fastBuilder.build(); + } + + private String getOperationTypeName(Map operationTypeDefs, + String operationName, + String defaultTypeName) { + OperationTypeDefinition opDef = operationTypeDefs.get(operationName); + if (opDef != null) { + return opDef.getTypeName().getName(); + } + return defaultTypeName; + } + + private GraphQLObjectType findOperationType(Set types, String typeName) { + for (GraphQLType type : types) { + if (type instanceof GraphQLObjectType) { + GraphQLObjectType objectType = (GraphQLObjectType) type; + if (objectType.getName().equals(typeName)) { + return objectType; + } + } + } + return null; + } +} diff --git a/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java b/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java index 00951ce91..1399c52c3 100644 --- a/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java +++ b/src/main/java/graphql/schema/idl/SchemaGeneratorHelper.java @@ -210,6 +210,17 @@ public Set getDirectives() { return directives; } + /** + * Returns all types that have been built so far (both input and output types). + * This is used by FastSchemaGenerator to collect types for FastBuilder. + */ + public Set getTypes() { + Set allTypes = new LinkedHashSet<>(); + allTypes.addAll(outputGTypes.values()); + allTypes.addAll(inputGTypes.values()); + return allTypes; + } + public boolean isCaptureAstDefinitions() { return options.isCaptureAstDefinitions(); } diff --git a/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy b/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy new file mode 100644 index 000000000..70da878cc --- /dev/null +++ b/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy @@ -0,0 +1,137 @@ +package graphql.schema.idl + +import graphql.schema.GraphQLSchema +import spock.lang.Specification + +class FastSchemaGeneratorTest extends Specification { + + def "can create simple schema using FastSchemaGenerator"() { + given: + def sdl = ''' + type Query { + hello: String + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + schema != null + schema.queryType.name == "Query" + schema.queryType.getFieldDefinition("hello") != null + } + + def "produces same result as standard SchemaGenerator"() { + given: + def sdl = ''' + type Query { + user(id: ID!): User + users: [User] + } + + type User { + id: ID! + name: String! + email: String + } + + type Mutation { + createUser(name: String!): User + } + ''' + + def registry = new SchemaParser().parse(sdl) + + when: + def standardSchema = new SchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING) + def fastSchema = new FastSchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING) + + then: + // Both should have the same query type + standardSchema.queryType.name == fastSchema.queryType.name + standardSchema.queryType.fieldDefinitions.size() == fastSchema.queryType.fieldDefinitions.size() + + // Both should have the same mutation type + standardSchema.mutationType.name == fastSchema.mutationType.name + standardSchema.mutationType.fieldDefinitions.size() == fastSchema.mutationType.fieldDefinitions.size() + + // Both should have User type with same fields + def standardUser = standardSchema.getObjectType("User") + def fastUser = fastSchema.getObjectType("User") + standardUser != null + fastUser != null + standardUser.fieldDefinitions.size() == fastUser.fieldDefinitions.size() + } + + def "handles schema with interfaces"() { + given: + def sdl = ''' + type Query { + node(id: ID!): Node + } + + interface Node { + id: ID! + } + + type User implements Node { + id: ID! + name: String! + } + + type Post implements Node { + id: ID! + title: String! + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + schema != null + schema.getType("Node") != null + schema.getType("User") != null + schema.getType("Post") != null + } + + def "handles schema with unions"() { + given: + def sdl = ''' + type Query { + search: SearchResult + } + + union SearchResult = User | Post + + type User { + id: ID! + name: String! + } + + type Post { + id: ID! + title: String! + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + schema != null + schema.getType("SearchResult") != null + schema.getType("User") != null + schema.getType("Post") != null + } +} From 388ab7335fa57f799be6bc31d000b16c62beb243 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sun, 11 Jan 2026 09:56:01 -0800 Subject: [PATCH 12/25] Address review comment, change a name A reviewer pointed out an unnecessary check was being performed, the commit removes it. Also, in re-reading that code I noticed that the behavior of `Builder.additionalType` and `FastBuilder.additionalType` were subtly different. To prevent potential confusion, this commit renames it to `addType` and documents its behavior more explicitly. --- .../java/graphql/schema/GraphQLSchema.java | 19 +-- .../schema/idl/FastSchemaGenerator.java | 4 +- .../schema/FastBuilderComparisonTest.groovy | 2 +- .../graphql/schema/FastBuilderTest.groovy | 148 +++++++++--------- 4 files changed, 87 insertions(+), 86 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 538025e95..cd383f139 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -1117,31 +1117,30 @@ public FastBuilder(GraphQLCodeRegistry.Builder codeRegistryBuilder, Introspection.addCodeForIntrospectionTypes(codeRegistryBuilder); // Add root types - additionalType(queryType); + addType(queryType); if (mutationType != null) { - additionalType(mutationType); + addType(mutationType); } if (subscriptionType != null) { - additionalType(subscriptionType); + addType(subscriptionType); } } /** * Adds a type to the schema. The type must be a named type (not a wrapper like List or NonNull). + * A type added by this method will be included in {@link GraphQLSchema#getAdditionalTypes()} + * only when it is not reachable from the schema root types. * * @param type the type to add * @return this builder for chaining */ - public FastBuilder additionalType(GraphQLType type) { + public FastBuilder addType(GraphQLType type) { if (type == null) { return this; } // Unwrap to named type GraphQLUnmodifiedType unwrapped = GraphQLTypeUtil.unwrapAll(type); - if (!(unwrapped instanceof GraphQLNamedType)) { - return this; - } GraphQLNamedType namedType = (GraphQLNamedType) unwrapped; String name = namedType.getName(); @@ -1185,13 +1184,15 @@ public FastBuilder additionalType(GraphQLType type) { /** * Adds multiple types to the schema. + * A type added by this method will be included in {@link GraphQLSchema#getAdditionalTypes()} + * only when it is not reachable from the schema root types. * * @param types the types to add * @return this builder for chaining */ - public FastBuilder additionalTypes(Collection types) { + public FastBuilder addTypes(Collection types) { if (types != null) { - types.forEach(this::additionalType); + types.forEach(this::addType); } return this; } diff --git a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java index 323624d6d..0a688d85d 100644 --- a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java +++ b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java @@ -104,8 +104,8 @@ private GraphQLSchema makeExecutableSchemaImpl(ImmutableTypeDefinitionRegistry t subscriptionType); // Add all built types - fastBuilder.additionalTypes(allBuiltTypes); - fastBuilder.additionalTypes(additionalTypes); + fastBuilder.addTypes(allBuiltTypes); + fastBuilder.addTypes(additionalTypes); // Add all directive definitions fastBuilder.additionalDirectives(additionalDirectives); diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy index 0d5fd8437..101ca0379 100644 --- a/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy @@ -65,7 +65,7 @@ class FastBuilderComparisonTest extends Specification { additionalTypes.each { type -> if (type != null) { - builder.additionalType(type) + builder.addType(type) } } diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index be794fedb..8ccc4a72b 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -44,8 +44,8 @@ class FastBuilderTest extends Specification { when: "adding both scalars" new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(scalar1) - .additionalType(scalar2) + .addType(scalar1) + .addType(scalar2) .build() then: "error is thrown" @@ -72,8 +72,8 @@ class FastBuilderTest extends Specification { when: "adding same scalar twice" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(scalar) - .additionalType(scalar) + .addType(scalar) + .addType(scalar) .build() then: "no error and scalar is in schema" @@ -94,7 +94,7 @@ class FastBuilderTest extends Specification { when: "adding null type" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(null) + .addType(null) .build() then: "no error" @@ -149,7 +149,7 @@ class FastBuilderTest extends Specification { when: "adding types as collection" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalTypes([scalar1, scalar2]) + .addTypes([scalar1, scalar2]) .build() then: "both types are in schema" @@ -186,7 +186,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(customScalar) + .addType(customScalar) .additionalDirective(directive) .build() @@ -223,7 +223,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(customScalar) + .addType(customScalar) .additionalDirective(directive) .build() @@ -261,7 +261,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(customScalar) + .addType(customScalar) .additionalDirective(directive) .build() @@ -437,7 +437,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(statusEnum) + .addType(statusEnum) .build() then: "enum type is in schema" @@ -479,7 +479,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(levelEnum) + .addType(levelEnum) .additionalDirective(directive) .build() @@ -516,7 +516,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(inputType) + .addType(inputType) .build() then: "input type is in schema" @@ -558,8 +558,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(customScalar) - .additionalType(inputType) + .addType(customScalar) + .addType(inputType) .build() then: "input field type is resolved" @@ -604,8 +604,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(addressInput) - .additionalType(userInput) + .addType(addressInput) + .addType(userInput) .build() then: "nested input field type is resolved" @@ -643,8 +643,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(statusEnum) - .additionalType(inputType) + .addType(statusEnum) + .addType(inputType) .build() then: "input field type is resolved with NonNull wrapper" @@ -686,8 +686,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(tagScalar) - .additionalType(inputType) + .addType(tagScalar) + .addType(inputType) .build() then: "input field type is resolved with List wrapper" @@ -726,7 +726,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(configInput) + .addType(configInput) .additionalDirective(directive) .build() @@ -773,7 +773,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(configScalar) + .addType(configScalar) .additionalDirective(directive) .withSchemaAppliedDirective(appliedDirective) .build() @@ -826,8 +826,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(customScalar) - .additionalType(enumType) + .addType(customScalar) + .addType(enumType) .additionalDirective(directive) .build() @@ -885,8 +885,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(customScalar) - .additionalType(inputType) + .addType(customScalar) + .addType(inputType) .additionalDirective(directive) .build() @@ -947,7 +947,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(personType) + .addType(personType) .build() then: "field type is resolved" @@ -975,7 +975,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(itemType) + .addType(itemType) .build() then: "field type is resolved with NonNull wrapper" @@ -1005,7 +1005,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(userType) + .addType(userType) .build() then: "field type is resolved with List wrapper" @@ -1047,8 +1047,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(nodeInterface) - .additionalType(postType) + .addType(nodeInterface) + .addType(postType) .build() then: "interface reference is resolved" @@ -1100,9 +1100,9 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(entityInterface) - .additionalType(userType) - .additionalType(productType) + .addType(entityInterface) + .addType(userType) + .addType(productType) .build() then: "interface to implementations map is built" @@ -1143,8 +1143,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(filterInput) - .additionalType(resultType) + .addType(filterInput) + .addType(resultType) .build() then: "field argument type is resolved" @@ -1189,7 +1189,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(metaScalar) + .addType(metaScalar) .additionalDirective(directive) .build() @@ -1238,7 +1238,7 @@ class FastBuilderTest extends Specification { when: "building" new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(objectType) + .addType(objectType) .build() then: "error for missing interface" @@ -1270,7 +1270,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(nodeInterface) + .addType(nodeInterface) .build() then: "interface type is in schema" @@ -1310,8 +1310,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(nodeInterface) - .additionalType(userType) + .addType(nodeInterface) + .addType(userType) .build() then: "interface field type is resolved" @@ -1355,8 +1355,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(nodeInterface) - .additionalType(namedNodeInterface) + .addType(nodeInterface) + .addType(namedNodeInterface) .build() then: "interface extension is resolved" @@ -1388,7 +1388,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(nodeInterface) + .addType(nodeInterface) .build() then: "type resolver is wired" @@ -1430,8 +1430,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(searchableInterface) - .additionalType(filterInput) + .addType(searchableInterface) + .addType(filterInput) .build() then: "interface field argument type is resolved" @@ -1463,7 +1463,7 @@ class FastBuilderTest extends Specification { when: "building" new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(childInterface) + .addType(childInterface) .build() then: "error for missing interface" @@ -1518,8 +1518,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(metaScalar) - .additionalType(nodeInterface) + .addType(metaScalar) + .addType(nodeInterface) .additionalDirective(directive) .build() @@ -1569,9 +1569,9 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(catType) - .additionalType(dogType) - .additionalType(petUnion) + .addType(catType) + .addType(dogType) + .addType(petUnion) .build() then: "union type is in schema" @@ -1617,9 +1617,9 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(catType) - .additionalType(dogType) - .additionalType(petUnion) + .addType(catType) + .addType(dogType) + .addType(petUnion) .build() then: "union member types are resolved" @@ -1658,8 +1658,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(catType) - .additionalType(petUnion) + .addType(catType) + .addType(petUnion) .build() then: "type resolver is wired" @@ -1688,7 +1688,7 @@ class FastBuilderTest extends Specification { when: "building" new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(petUnion) + .addType(petUnion) .build() then: "error for missing type" @@ -1749,9 +1749,9 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(metaScalar) - .additionalType(catType) - .additionalType(petUnion) + .addType(metaScalar) + .addType(catType) + .addType(petUnion) .additionalDirective(directive) .build() @@ -1784,7 +1784,7 @@ class FastBuilderTest extends Specification { when: "building with validation disabled" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(nodeInterface) + .addType(nodeInterface) .withValidation(false) .build() @@ -1826,8 +1826,8 @@ class FastBuilderTest extends Specification { when: "building with validation enabled" new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(nodeInterface) - .additionalType(badImplementor) + .addType(nodeInterface) + .addType(badImplementor) .withValidation(true) .build() @@ -1858,7 +1858,7 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(personType) + .addType(personType) .build() then: "circular reference is resolved" @@ -1894,8 +1894,8 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(innerType) - .additionalType(outerType) + .addType(innerType) + .addType(outerType) .build() then: "deeply nested type reference is resolved" @@ -1985,11 +1985,11 @@ class FastBuilderTest extends Specification { when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .additionalType(nodeInterface) - .additionalType(filterInput) - .additionalType(userType) - .additionalType(postType) - .additionalType(searchResultUnion) + .addType(nodeInterface) + .addType(filterInput) + .addType(userType) + .addType(postType) + .addType(searchResultUnion) .build() then: "all types are resolved correctly" @@ -2031,8 +2031,8 @@ class FastBuilderTest extends Specification { when: "adding null types and directives" def schema = new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .additionalType(null) - .additionalTypes(null) + .addType(null) + .addTypes(null) .additionalDirective(null) .additionalDirectives(null) .withSchemaDirective(null) From 62815b1754894e9eb54756ea2a9afaa7bc646889 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Tue, 20 Jan 2026 07:58:38 -0800 Subject: [PATCH 13/25] Tighter type-bounds for GraphQLSchema.additionalTypes. --- src/main/java/graphql/schema/idl/FastSchemaGenerator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java index 0a688d85d..6efef74e9 100644 --- a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java +++ b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java @@ -4,6 +4,7 @@ import graphql.language.OperationTypeDefinition; import graphql.schema.GraphQLCodeRegistry; import graphql.schema.GraphQLDirective; +import graphql.schema.GraphQLNamedType; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLSchema; import graphql.schema.GraphQLType; @@ -72,7 +73,7 @@ private GraphQLSchema makeExecutableSchemaImpl(ImmutableTypeDefinitionRegistry t schemaGeneratorHelper.buildOperations(buildCtx, tempBuilder); // Build all additional types - Set additionalTypes = schemaGeneratorHelper.buildAdditionalTypes(buildCtx); + Set additionalTypes = schemaGeneratorHelper.buildAdditionalTypes(buildCtx); // Set field visibility on code registry buildCtx.getCodeRegistry().fieldVisibility(buildCtx.getWiring().getFieldVisibility()); From 1c54eae48093446aad0cde9b97d418bf688e7789 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Tue, 20 Jan 2026 11:23:23 -0800 Subject: [PATCH 14/25] Remove detached-type detection: all types are additionalTypes --- .../java/graphql/schema/GraphQLSchema.java | 25 +- .../schema/impl/FindDetachedTypes.java | 171 --- ...tBuilderAdditionalTypesSemanticTest.groovy | 204 ++++ ...uilderComparisonAdditionalTypesTest.groovy | 713 ------------ .../schema/FastBuilderComparisonTest.groovy | 13 +- .../schema/impl/FindDetachedTypesTest.groovy | 1017 ----------------- 6 files changed, 231 insertions(+), 1912 deletions(-) delete mode 100644 src/main/java/graphql/schema/impl/FindDetachedTypes.java create mode 100644 src/test/groovy/graphql/schema/FastBuilderAdditionalTypesSemanticTest.groovy delete mode 100644 src/test/groovy/graphql/schema/FastBuilderComparisonAdditionalTypesTest.groovy delete mode 100644 src/test/groovy/graphql/schema/impl/FindDetachedTypesTest.groovy diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index cd383f139..c874ef280 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -15,7 +15,6 @@ import graphql.introspection.Introspection; import graphql.language.SchemaDefinition; import graphql.language.SchemaExtensionDefinition; -import graphql.schema.impl.FindDetachedTypes; import graphql.schema.impl.GraphQLTypeCollectingVisitor; import graphql.schema.impl.SchemaUtil; import graphql.schema.validation.InvalidSchemaException; @@ -194,8 +193,21 @@ private GraphQLSchema(FastBuilder fastBuilder) { this.mutationType = fastBuilder.mutationType; this.subscriptionType = fastBuilder.subscriptionType; this.introspectionSchemaType = fastBuilder.introspectionSchemaType; - this.additionalTypes = ImmutableSet.copyOf(FindDetachedTypes.findDetachedTypes( - finalTypeMap, fastBuilder.queryType, fastBuilder.mutationType, fastBuilder.subscriptionType, finalDirectives)); + // Compute additionalTypes as all types minus root types. + // Note: Unlike the standard Builder which computes only "detached" types (types not + // reachable from roots), FastBuilder includes ALL non-root types in additionalTypes. + // This is a semantic difference but does not affect schema traversal or correctness. + Set rootTypeNames = new LinkedHashSet<>(); + rootTypeNames.add(fastBuilder.queryType.getName()); + if (fastBuilder.mutationType != null) { + rootTypeNames.add(fastBuilder.mutationType.getName()); + } + if (fastBuilder.subscriptionType != null) { + rootTypeNames.add(fastBuilder.subscriptionType.getName()); + } + this.additionalTypes = finalTypeMap.values().stream() + .filter(type -> !rootTypeNames.contains(type.getName())) + .collect(ImmutableSet.toImmutableSet()); this.introspectionSchemaField = Introspection.buildSchemaField(fastBuilder.introspectionSchemaType); this.introspectionTypeField = Introspection.buildTypeField(fastBuilder.introspectionSchemaType); this.directiveDefinitionsHolder = new DirectivesUtil.DirectivesHolder(finalDirectives, emptyList()); @@ -323,6 +335,13 @@ public GraphQLObjectType getIntrospectionSchemaType() { * 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. + *

+ * Note on FastBuilder: When a schema is constructed using {@link FastBuilder}, + * this method returns ALL types in the schema except the root operation types (Query, + * Mutation, Subscription). This differs from schemas built with the standard + * {@link Builder}, which returns only types not reachable from the root types. + * This semantic difference does not affect schema traversal or correctness, as both + * approaches ensure all types are properly discoverable. * * @return an immutable set of types that were explicitly added as additional types * diff --git a/src/main/java/graphql/schema/impl/FindDetachedTypes.java b/src/main/java/graphql/schema/impl/FindDetachedTypes.java deleted file mode 100644 index a42141e90..000000000 --- a/src/main/java/graphql/schema/impl/FindDetachedTypes.java +++ /dev/null @@ -1,171 +0,0 @@ -package graphql.schema.impl; - -import graphql.Internal; -import graphql.schema.GraphQLArgument; -import graphql.schema.GraphQLDirective; -import graphql.schema.GraphQLFieldDefinition; -import graphql.schema.GraphQLInputObjectField; -import graphql.schema.GraphQLInputObjectType; -import graphql.schema.GraphQLInterfaceType; -import graphql.schema.GraphQLList; -import graphql.schema.GraphQLNamedType; -import graphql.schema.GraphQLNonNull; -import graphql.schema.GraphQLObjectType; -import graphql.schema.GraphQLOutputType; -import graphql.schema.GraphQLType; -import graphql.schema.GraphQLUnionType; - -import java.util.Collection; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -/** - * Finds detached types in a schema by performing a DFS traversal from root types - * and directive definitions to find all reachable (attached) types, then computing the complement. - */ -@Internal -public class FindDetachedTypes { - - /** - * Computes the set of detached types - types that exist in the typeMap but are not - * reachable from the root types (Query, Mutation, Subscription) or directive definitions. - * - * @param typeMap all types in the schema - * @param queryType the query root type (required) - * @param mutationType the mutation root type (may be null) - * @param subscriptionType the subscription root type (may be null) - * @param directives directive definitions in the schema - * @return set of types that are not reachable from root types or directives - */ - public static Set findDetachedTypes(Map typeMap, - GraphQLObjectType queryType, - GraphQLObjectType mutationType, - GraphQLObjectType subscriptionType, - Collection directives) { - int typeCount = typeMap.size(); - Set attachedTypeNames = new HashSet<>(typeCount); - - // DFS from each root type to find all reachable types - visitType(queryType, attachedTypeNames); - if (mutationType != null) { - visitType(mutationType, attachedTypeNames); - } - if (subscriptionType != null) { - visitType(subscriptionType, attachedTypeNames); - } - - // Also visit types reachable from directive argument definitions - for (GraphQLDirective directive : directives) { - visitDirective(directive, attachedTypeNames); - } - - // Detached types = all types minus attached types - // Use Math.max to ensure capacity is never negative (can happen if attached types include - // types not in typeMap, like built-in scalars) - int detachedCapacity = Math.max(0, typeCount - attachedTypeNames.size()); - Set detachedTypes = new HashSet<>(detachedCapacity); - for (GraphQLNamedType type : typeMap.values()) { - if (!attachedTypeNames.contains(type.getName())) { - detachedTypes.add(type); - } - } - - return detachedTypes; - } - - private static void visitDirective(GraphQLDirective directive, Set visited) { - // Visit argument types in the directive definition - for (GraphQLArgument arg : directive.getArguments()) { - visitType(arg.getType(), visited); - } - } - - private static void visitType(GraphQLType type, Set visited) { - // Unwrap modifiers (NonNull, List) - GraphQLType unwrapped = unwrapType(type); - - if (!(unwrapped instanceof GraphQLNamedType)) { - return; - } - - GraphQLNamedType namedType = (GraphQLNamedType) unwrapped; - String typeName = namedType.getName(); - - // Skip if already visited - if (visited.contains(typeName)) { - return; - } - visited.add(typeName); - - // Visit fields and their types recursively - if (namedType instanceof GraphQLObjectType) { - visitObjectType((GraphQLObjectType) namedType, visited); - } else if (namedType instanceof GraphQLInterfaceType) { - visitInterfaceType((GraphQLInterfaceType) namedType, visited); - } else if (namedType instanceof GraphQLUnionType) { - visitUnionType((GraphQLUnionType) namedType, visited); - } else if (namedType instanceof GraphQLInputObjectType) { - visitInputObjectType((GraphQLInputObjectType) namedType, visited); - } - // Scalars and Enums have no further types to visit - } - - private static void visitObjectType(GraphQLObjectType objectType, Set visited) { - // Visit interfaces this object implements - for (GraphQLOutputType iface : objectType.getInterfaces()) { - visitType(iface, visited); - } - - // Visit field types - for (GraphQLFieldDefinition field : objectType.getFieldDefinitions()) { - visitType(field.getType(), visited); - // Visit argument types - for (GraphQLArgument arg : field.getArguments()) { - visitType(arg.getType(), visited); - } - } - } - - private static void visitInterfaceType(GraphQLInterfaceType interfaceType, Set visited) { - // Visit interfaces this interface extends - for (GraphQLOutputType iface : interfaceType.getInterfaces()) { - visitType(iface, visited); - } - - // Visit field types - for (GraphQLFieldDefinition field : interfaceType.getFieldDefinitions()) { - visitType(field.getType(), visited); - // Visit argument types - for (GraphQLArgument arg : field.getArguments()) { - visitType(arg.getType(), visited); - } - } - } - - private static void visitUnionType(GraphQLUnionType unionType, Set visited) { - // Visit all possible types in the union - for (GraphQLOutputType possibleType : unionType.getTypes()) { - visitType(possibleType, visited); - } - } - - private static void visitInputObjectType(GraphQLInputObjectType inputObjectType, Set visited) { - // Visit field types - for (GraphQLInputObjectField field : inputObjectType.getFieldDefinitions()) { - visitType(field.getType(), visited); - } - } - - private static GraphQLType unwrapType(GraphQLType type) { - GraphQLType current = type; - while (current instanceof GraphQLNonNull || current instanceof GraphQLList) { - if (current instanceof GraphQLNonNull) { - current = ((GraphQLNonNull) current).getWrappedType(); - } else { - current = ((GraphQLList) current).getWrappedType(); - } - } - return current; - } -} diff --git a/src/test/groovy/graphql/schema/FastBuilderAdditionalTypesSemanticTest.groovy b/src/test/groovy/graphql/schema/FastBuilderAdditionalTypesSemanticTest.groovy new file mode 100644 index 000000000..deeadde19 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderAdditionalTypesSemanticTest.groovy @@ -0,0 +1,204 @@ +package graphql.schema + +import graphql.schema.idl.FastSchemaGenerator +import graphql.schema.idl.RuntimeWiring +import graphql.schema.idl.SchemaParser +import spock.lang.Specification + +/** + * Tests that verify FastBuilder's additionalTypes semantics: + * additionalTypes contains ALL types except root operation types. + * + * This differs from the standard Builder which only includes "detached" types + * (types not reachable from root types). + */ +class FastBuilderAdditionalTypesSemanticTest extends Specification { + + def "additionalTypes contains all types except Query when only Query root exists"() { + given: + def sdl = ''' + type Query { + user: User + } + type User { + name: String + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + def additionalTypeNames = schema.additionalTypes*.name.toSet() + + // Query should NOT be in additionalTypes (it's a root type) + !additionalTypeNames.contains("Query") + + // User should be in additionalTypes (non-root type) + additionalTypeNames.contains("User") + } + + def "additionalTypes excludes Query, Mutation, and Subscription root types"() { + given: + def sdl = ''' + type Query { + user: User + } + type Mutation { + createUser: User + } + type Subscription { + userCreated: User + } + type User { + name: String + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + def additionalTypeNames = schema.additionalTypes*.name.toSet() + + // Root types should NOT be in additionalTypes + !additionalTypeNames.contains("Query") + !additionalTypeNames.contains("Mutation") + !additionalTypeNames.contains("Subscription") + + // User should be in additionalTypes + additionalTypeNames.contains("User") + } + + def "additionalTypes includes types reachable from roots (unlike standard builder)"() { + given: "SDL where User is reachable from Query" + def sdl = ''' + type Query { + user: User + } + type User { + name: String + address: Address + } + type Address { + city: String + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: "FastBuilder includes all non-root types in additionalTypes" + def additionalTypeNames = schema.additionalTypes*.name.toSet() + + // Both User and Address are included even though they're reachable from Query + additionalTypeNames.contains("User") + additionalTypeNames.contains("Address") + + // Query is still excluded + !additionalTypeNames.contains("Query") + } + + def "additionalTypes includes interface implementations"() { + given: + def sdl = ''' + type Query { + node: Node + } + interface Node { + id: ID + } + type User implements Node { + id: ID + name: String + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + def additionalTypeNames = schema.additionalTypes*.name.toSet() + + additionalTypeNames.contains("Node") + additionalTypeNames.contains("User") + !additionalTypeNames.contains("Query") + } + + def "additionalTypes includes enum, input, and scalar types"() { + given: + def sdl = ''' + type Query { + status: Status + search(input: SearchInput): String + } + enum Status { + ACTIVE + INACTIVE + } + input SearchInput { + query: String + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + def additionalTypeNames = schema.additionalTypes*.name.toSet() + + additionalTypeNames.contains("Status") + additionalTypeNames.contains("SearchInput") + !additionalTypeNames.contains("Query") + } + + def "additionalTypes with custom root type names"() { + given: + def sdl = ''' + schema { + query: MyQuery + mutation: MyMutation + } + type MyQuery { + value: String + } + type MyMutation { + setValue: String + } + type Helper { + data: String + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + def additionalTypeNames = schema.additionalTypes*.name.toSet() + + // Custom root type names should be excluded + !additionalTypeNames.contains("MyQuery") + !additionalTypeNames.contains("MyMutation") + + // Non-root types included + additionalTypeNames.contains("Helper") + } +} diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonAdditionalTypesTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonAdditionalTypesTest.groovy deleted file mode 100644 index d475fd71b..000000000 --- a/src/test/groovy/graphql/schema/FastBuilderComparisonAdditionalTypesTest.groovy +++ /dev/null @@ -1,713 +0,0 @@ -package graphql.schema - -import spock.lang.Specification - -import static graphql.Scalars.GraphQLString -import static graphql.schema.GraphQLArgument.newArgument -import static graphql.schema.GraphQLDirective.newDirective -import static graphql.schema.GraphQLEnumType.newEnum -import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition -import static graphql.schema.GraphQLInputObjectField.newInputObjectField -import static graphql.schema.GraphQLInputObjectType.newInputObject -import static graphql.schema.GraphQLObjectType.newObject -import static graphql.schema.GraphQLScalarType.newScalar -import static graphql.introspection.Introspection.DirectiveLocation - -/** - * Comparison tests for AdditionalTypes (detached types) between FastBuilder and standard Builder. - * - * Detached types are types that exist in the schema but are not reachable from root types - * (Query, Mutation, Subscription) or directive arguments. These tests verify that FastBuilder's - * FindDetachedTypes implementation produces the same additionalTypes set as the standard builder. - */ -class FastBuilderComparisonAdditionalTypesTest extends FastBuilderComparisonTest { - - def "schema with detached type not reachable from roots has matching additionalTypes"() { - given: "SDL with a detached type" - def sdl = """ - type Query { - value: String - } - - # DetachedType is not referenced anywhere - it's detached - type DetachedType { - field: String - } - """ - - and: "programmatically created types" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def detachedType = newObject() - .name("DetachedType") - .field(newFieldDefinition() - .name("field") - .type(GraphQLString)) - .build() - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [detachedType]) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "both have DetachedType in additionalTypes" - standardSchema.additionalTypes*.name.toSet().contains("DetachedType") - fastSchema.additionalTypes*.name.toSet().contains("DetachedType") - } - - def "schema with type reachable from Query does not include it in additionalTypes"() { - given: "SDL with type reachable from Query" - def sdl = """ - type Query { - user: User - } - - type User { - name: String - } - """ - - and: "programmatically created types" - def userType = newObject() - .name("User") - .field(newFieldDefinition() - .name("name") - .type(GraphQLString)) - .build() - - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("user") - .type(userType)) - .build() - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [userType]) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "User is NOT in additionalTypes for either schema (it's reachable from Query)" - !standardSchema.additionalTypes*.name.toSet().contains("User") - !fastSchema.additionalTypes*.name.toSet().contains("User") - } - - def "schema with type reachable from Mutation does not include it in additionalTypes"() { - given: "SDL with type reachable from Mutation" - def sdl = """ - type Query { - value: String - } - - type Mutation { - createUser(input: CreateUserInput): User - } - - input CreateUserInput { - name: String - } - - type User { - name: String - } - """ - - and: "programmatically created types" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def inputType = newInputObject() - .name("CreateUserInput") - .field(newInputObjectField() - .name("name") - .type(GraphQLString)) - .build() - - def userType = newObject() - .name("User") - .field(newFieldDefinition() - .name("name") - .type(GraphQLString)) - .build() - - def mutationType = newObject() - .name("Mutation") - .field(newFieldDefinition() - .name("createUser") - .argument(newArgument() - .name("input") - .type(inputType)) - .type(userType)) - .build() - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder(queryType, mutationType, null, [userType, inputType]) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "User and CreateUserInput are NOT in additionalTypes (reachable from Mutation)" - !standardSchema.additionalTypes*.name.toSet().contains("User") - !fastSchema.additionalTypes*.name.toSet().contains("User") - !standardSchema.additionalTypes*.name.toSet().contains("CreateUserInput") - !fastSchema.additionalTypes*.name.toSet().contains("CreateUserInput") - } - - def "schema with type reachable from Subscription does not include it in additionalTypes"() { - given: "SDL with type reachable from Subscription" - def sdl = """ - type Query { - value: String - } - - type Subscription { - userUpdated: UserUpdate - } - - type UserUpdate { - user: User - } - - type User { - name: String - } - """ - - and: "programmatically created types" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def userType = newObject() - .name("User") - .field(newFieldDefinition() - .name("name") - .type(GraphQLString)) - .build() - - def userUpdateType = newObject() - .name("UserUpdate") - .field(newFieldDefinition() - .name("user") - .type(userType)) - .build() - - def subscriptionType = newObject() - .name("Subscription") - .field(newFieldDefinition() - .name("userUpdated") - .type(userUpdateType)) - .build() - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder(queryType, null, subscriptionType, [userType, userUpdateType]) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "UserUpdate and User are NOT in additionalTypes (reachable from Subscription)" - !standardSchema.additionalTypes*.name.toSet().contains("UserUpdate") - !fastSchema.additionalTypes*.name.toSet().contains("UserUpdate") - !standardSchema.additionalTypes*.name.toSet().contains("User") - !fastSchema.additionalTypes*.name.toSet().contains("User") - } - - def "schema with type reachable only via directive argument does not include it in additionalTypes"() { - given: "SDL with type only used in directive argument" - def sdl = """ - type Query { - value: String - } - - # ConfigValue is only used in the directive argument - scalar ConfigValue - - directive @config(value: ConfigValue) on FIELD - """ - - and: "programmatically created types" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def configScalar = newScalar() - .name("ConfigValue") - .coercing(GraphQLString.getCoercing()) - .build() - - def directive = newDirective() - .name("config") - .validLocation(DirectiveLocation.FIELD) - .argument(newArgument() - .name("value") - .type(configScalar)) - .build() - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder( - queryType, - null, - null, - [configScalar], - [directive] - ) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "ConfigValue is NOT in additionalTypes (reachable via directive argument)" - !standardSchema.additionalTypes*.name.toSet().contains("ConfigValue") - !fastSchema.additionalTypes*.name.toSet().contains("ConfigValue") - } - - def "complex schema with multiple detached types has matching additionalTypes"() { - given: "SDL with multiple detached types" - def sdl = """ - type Query { - user: User - } - - type User { - name: String - } - - # These types are all detached (not reachable from Query) - type DetachedOne { - field: String - } - - type DetachedTwo { - field: String - } - - enum DetachedEnum { - VALUE_A - VALUE_B - } - - input DetachedInput { - field: String - } - """ - - and: "programmatically created types" - def userType = newObject() - .name("User") - .field(newFieldDefinition() - .name("name") - .type(GraphQLString)) - .build() - - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("user") - .type(userType)) - .build() - - def detachedOne = newObject() - .name("DetachedOne") - .field(newFieldDefinition() - .name("field") - .type(GraphQLString)) - .build() - - def detachedTwo = newObject() - .name("DetachedTwo") - .field(newFieldDefinition() - .name("field") - .type(GraphQLString)) - .build() - - def detachedEnum = newEnum() - .name("DetachedEnum") - .value("VALUE_A") - .value("VALUE_B") - .build() - - def detachedInput = newInputObject() - .name("DetachedInput") - .field(newInputObjectField() - .name("field") - .type(GraphQLString)) - .build() - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder( - queryType, - null, - null, - [userType, detachedOne, detachedTwo, detachedEnum, detachedInput] - ) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "all detached types are in additionalTypes" - def standardAdditional = standardSchema.additionalTypes*.name.toSet() - def fastAdditional = fastSchema.additionalTypes*.name.toSet() - - standardAdditional.contains("DetachedOne") - standardAdditional.contains("DetachedTwo") - standardAdditional.contains("DetachedEnum") - standardAdditional.contains("DetachedInput") - - fastAdditional.contains("DetachedOne") - fastAdditional.contains("DetachedTwo") - fastAdditional.contains("DetachedEnum") - fastAdditional.contains("DetachedInput") - - and: "User is NOT in additionalTypes (it's reachable)" - !standardAdditional.contains("User") - !fastAdditional.contains("User") - } - - def "schema with no detached types has empty additionalTypes in both builders"() { - given: "SDL with all types reachable from Query" - def sdl = """ - type Query { - user: User - post: Post - } - - type User { - name: String - posts: [Post] - } - - type Post { - title: String - author: User - } - """ - - and: "programmatically created types" - def userType = newObject() - .name("User") - .field(newFieldDefinition() - .name("name") - .type(GraphQLString)) - .build() - - def postType = newObject() - .name("Post") - .field(newFieldDefinition() - .name("title") - .type(GraphQLString)) - .field(newFieldDefinition() - .name("author") - .type(userType)) - .build() - - // Add posts field to userType after postType is created - userType = userType.transform({ builder -> - builder.field(newFieldDefinition() - .name("posts") - .type(GraphQLList.list(postType))) - }) - - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("user") - .type(userType)) - .field(newFieldDefinition() - .name("post") - .type(postType)) - .build() - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [userType, postType]) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "both have empty additionalTypes (all types are reachable)" - standardSchema.additionalTypes.isEmpty() - fastSchema.additionalTypes.isEmpty() - } - - def "schema with detached type transitively referencing other types includes all in additionalTypes"() { - given: "SDL with detached types that reference each other" - def sdl = """ - type Query { - value: String - } - - # DetachedOne is not reachable from Query - type DetachedOne { - nested: DetachedTwo - } - - # DetachedTwo is referenced by DetachedOne, but neither is reachable - type DetachedTwo { - field: String - } - """ - - and: "programmatically created types" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def detachedTwo = newObject() - .name("DetachedTwo") - .field(newFieldDefinition() - .name("field") - .type(GraphQLString)) - .build() - - def detachedOne = newObject() - .name("DetachedOne") - .field(newFieldDefinition() - .name("nested") - .type(detachedTwo)) - .build() - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [detachedOne, detachedTwo]) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "both DetachedOne and DetachedTwo are in additionalTypes" - def standardAdditional = standardSchema.additionalTypes*.name.toSet() - def fastAdditional = fastSchema.additionalTypes*.name.toSet() - - standardAdditional.contains("DetachedOne") - standardAdditional.contains("DetachedTwo") - fastAdditional.contains("DetachedOne") - fastAdditional.contains("DetachedTwo") - } - - def "schema with type implementing interface has correct additionalTypes"() { - given: "SDL with interface and implementation" - def sdl = """ - type Query { - node: Node - } - - interface Node { - id: String - } - - # User implements Node - it's in additionalTypes because interface implementations - # are not automatically traversed from the interface type itself - type User implements Node { - id: String - name: String - } - """ - - and: "programmatically created types" - def nodeInterface = GraphQLInterfaceType.newInterface() - .name("Node") - .field(newFieldDefinition() - .name("id") - .type(GraphQLString)) - .build() - - def userType = newObject() - .name("User") - .withInterface(nodeInterface) - .field(newFieldDefinition() - .name("id") - .type(GraphQLString)) - .field(newFieldDefinition() - .name("name") - .type(GraphQLString)) - .build() - - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("node") - .type(nodeInterface)) - .build() - - def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() - .typeResolver("Node", { env -> userType }) - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [nodeInterface, userType], [], codeRegistry) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "Node is NOT in additionalTypes (reachable from Query)" - !standardSchema.additionalTypes*.name.toSet().contains("Node") - !fastSchema.additionalTypes*.name.toSet().contains("Node") - - and: "User IS in additionalTypes (interface implementations are not auto-traversed)" - standardSchema.additionalTypes*.name.toSet().contains("User") - fastSchema.additionalTypes*.name.toSet().contains("User") - } - - def "schema with type used in union is not in additionalTypes"() { - given: "SDL with union type" - def sdl = """ - type Query { - searchResult: SearchResult - } - - union SearchResult = User | Post - - type User { - name: String - } - - type Post { - title: String - } - """ - - and: "programmatically created types" - def userType = newObject() - .name("User") - .field(newFieldDefinition() - .name("name") - .type(GraphQLString)) - .build() - - def postType = newObject() - .name("Post") - .field(newFieldDefinition() - .name("title") - .type(GraphQLString)) - .build() - - def searchResultUnion = GraphQLUnionType.newUnionType() - .name("SearchResult") - .possibleType(userType) - .possibleType(postType) - .build() - - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("searchResult") - .type(searchResultUnion)) - .build() - - def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() - .typeResolver("SearchResult", { env -> null }) - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder( - queryType, - null, - null, - [userType, postType, searchResultUnion], - [], - codeRegistry - ) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "SearchResult, User, and Post are NOT in additionalTypes (all reachable from Query)" - def standardAdditional = standardSchema.additionalTypes*.name.toSet() - def fastAdditional = fastSchema.additionalTypes*.name.toSet() - - !standardAdditional.contains("SearchResult") - !standardAdditional.contains("User") - !standardAdditional.contains("Post") - - !fastAdditional.contains("SearchResult") - !fastAdditional.contains("User") - !fastAdditional.contains("Post") - } - - def "schema with type used in input object field is not in additionalTypes when input is reachable"() { - given: "SDL with nested input types" - def sdl = """ - type Query { - createUser(input: UserInput): String - } - - input UserInput { - name: String - address: AddressInput - } - - input AddressInput { - street: String - city: String - } - """ - - and: "programmatically created types" - def addressInput = newInputObject() - .name("AddressInput") - .field(newInputObjectField() - .name("street") - .type(GraphQLString)) - .field(newInputObjectField() - .name("city") - .type(GraphQLString)) - .build() - - def userInput = newInputObject() - .name("UserInput") - .field(newInputObjectField() - .name("name") - .type(GraphQLString)) - .field(newInputObjectField() - .name("address") - .type(addressInput)) - .build() - - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("createUser") - .argument(newArgument() - .name("input") - .type(userInput)) - .type(GraphQLString)) - .build() - - when: "building with both approaches" - def standardSchema = buildSchemaFromSDL(sdl) - def fastSchema = buildSchemaWithFastBuilder(queryType, null, null, [userInput, addressInput]) - - then: "schemas are equivalent" - assertSchemasEquivalent(fastSchema, standardSchema) - - and: "UserInput and AddressInput are NOT in additionalTypes (both reachable from Query)" - !standardSchema.additionalTypes*.name.toSet().contains("UserInput") - !fastSchema.additionalTypes*.name.toSet().contains("UserInput") - !standardSchema.additionalTypes*.name.toSet().contains("AddressInput") - !fastSchema.additionalTypes*.name.toSet().contains("AddressInput") - } -} diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy index 101ca0379..b6cd20196 100644 --- a/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy @@ -100,10 +100,13 @@ class FastBuilderComparisonTest extends Specification { * * This checks: * - Type map keys match (excluding introspection types and built-in scalars) - * - Additional types match (as sets, order doesn't matter) * - Interface implementations match (as lists, order matters - alphabetically sorted) * - Core directive names are present (allows experimental directives to differ) * - Root types match + * + * Note: additionalTypes is NOT compared because FastBuilder and standard Builder have + * different semantics - FastBuilder includes all non-root types, while standard Builder + * includes only types not reachable from roots. */ void assertSchemasEquivalent(GraphQLSchema fastSchema, GraphQLSchema standardSchema) { // Check type map keys match (excluding introspection types and built-in scalars which may differ) @@ -114,13 +117,7 @@ class FastBuilderComparisonTest extends Specification { "FastBuilder types: ${fastTypes}\n" + "Standard types: ${standardTypes}" - // Check additional types match (as sets - order doesn't matter for detached types) - def fastAdditionalTypes = fastSchema.additionalTypes*.name.toSet() - def standardAdditionalTypes = standardSchema.additionalTypes*.name.toSet() - assert fastAdditionalTypes == standardAdditionalTypes, - "Additional types differ:\n" + - "FastBuilder: ${fastAdditionalTypes}\n" + - "Standard: ${standardAdditionalTypes}" + // Note: additionalTypes is NOT compared - see method Javadoc for explanation // Check interface implementations (order matters - should be alphabetically sorted) // Only check user-defined interfaces (not introspection interfaces) diff --git a/src/test/groovy/graphql/schema/impl/FindDetachedTypesTest.groovy b/src/test/groovy/graphql/schema/impl/FindDetachedTypesTest.groovy deleted file mode 100644 index ac66b7a10..000000000 --- a/src/test/groovy/graphql/schema/impl/FindDetachedTypesTest.groovy +++ /dev/null @@ -1,1017 +0,0 @@ -package graphql.schema.impl - -import graphql.Scalars -import graphql.introspection.Introspection -import graphql.schema.GraphQLDirective -import graphql.schema.GraphQLNamedType -import graphql.schema.GraphQLObjectType -import spock.lang.Specification - -import static graphql.Scalars.GraphQLInt -import static graphql.Scalars.GraphQLString -import static graphql.schema.GraphQLArgument.newArgument -import static graphql.schema.GraphQLDirective.newDirective -import static graphql.schema.GraphQLEnumType.newEnum -import static graphql.schema.GraphQLEnumValueDefinition.newEnumValueDefinition -import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition -import static graphql.schema.GraphQLInputObjectField.newInputObjectField -import static graphql.schema.GraphQLInputObjectType.newInputObject -import static graphql.schema.GraphQLInterfaceType.newInterface -import static graphql.schema.GraphQLList.list -import static graphql.schema.GraphQLNonNull.nonNull -import static graphql.schema.GraphQLObjectType.newObject -import static graphql.schema.GraphQLScalarType.newScalar -import static graphql.schema.GraphQLUnionType.newUnionType - -class FindDetachedTypesTest extends Specification { - - def "type not reachable from any root is detached"() { - given: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "a detached type" - def detachedType = newObject() - .name("DetachedType") - .field(newFieldDefinition() - .name("id") - .type(GraphQLString)) - .build() - - and: "type map including both (built-in scalars are attached via Query field)" - def typeMap = [ - "Query" : queryType, - "DetachedType": detachedType, - "String" : GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "only DetachedType is detached" - detached*.name as Set == ["DetachedType"] as Set - } - - def "type reachable from Query is attached"() { - given: "a custom type" - def customType = newObject() - .name("CustomType") - .field(newFieldDefinition() - .name("id") - .type(GraphQLInt)) - .build() - - and: "a query type that references the custom type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("custom") - .type(customType)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "CustomType": customType, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "custom type is attached (not detached)" - detached.empty - } - - def "type reachable from Mutation is attached"() { - given: "a custom type" - def customType = newObject() - .name("CustomType") - .field(newFieldDefinition() - .name("id") - .type(GraphQLInt)) - .build() - - and: "a mutation type that references the custom type" - def mutationType = newObject() - .name("Mutation") - .field(newFieldDefinition() - .name("createCustom") - .type(customType)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "Mutation" : mutationType, - "CustomType": customType, - "String" : GraphQLString, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, mutationType, null, []) - - then: "custom type is attached (not detached)" - detached.empty - } - - def "type reachable from Subscription is attached"() { - given: "a custom type" - def customType = newObject() - .name("CustomType") - .field(newFieldDefinition() - .name("id") - .type(GraphQLInt)) - .build() - - and: "a subscription type that references the custom type" - def subscriptionType = newObject() - .name("Subscription") - .field(newFieldDefinition() - .name("customChanged") - .type(customType)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "Subscription": subscriptionType, - "CustomType" : customType, - "String" : GraphQLString, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, subscriptionType, []) - - then: "custom type is attached (not detached)" - detached.empty - } - - def "root types themselves are attached"() { - given: "query, mutation, and subscription types" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def mutationType = newObject() - .name("Mutation") - .field(newFieldDefinition() - .name("setValue") - .type(GraphQLString)) - .build() - - def subscriptionType = newObject() - .name("Subscription") - .field(newFieldDefinition() - .name("valueChanged") - .type(GraphQLString)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "Mutation" : mutationType, - "Subscription": subscriptionType, - "String" : GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, mutationType, subscriptionType, []) - - then: "no root types are detached" - detached.empty - } - - def "object type field types are followed"() { - given: "a nested type" - def nestedType = newObject() - .name("NestedType") - .field(newFieldDefinition() - .name("value") - .type(GraphQLInt)) - .build() - - and: "an object type with field of nested type" - def parentType = newObject() - .name("ParentType") - .field(newFieldDefinition() - .name("nested") - .type(nestedType)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("parent") - .type(parentType)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "ParentType": parentType, - "NestedType": nestedType, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "nested type is attached (not detached)" - detached.empty - } - - def "object type field argument types are followed"() { - given: "an input type" - def inputType = newInputObject() - .name("InputType") - .field(newInputObjectField() - .name("value") - .type(GraphQLInt)) - .build() - - and: "a query type with field that has argument of input type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("search") - .type(GraphQLString) - .argument(newArgument() - .name("filter") - .type(inputType))) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "InputType": inputType, - "String" : GraphQLString, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "input type is attached (not detached)" - detached.empty - } - - def "object type interface implementations are followed"() { - given: "an interface type" - def interfaceType = newInterface() - .name("Node") - .field(newFieldDefinition() - .name("id") - .type(GraphQLString)) - .build() - - and: "an object type implementing the interface" - def objectType = newObject() - .name("User") - .withInterface(interfaceType) - .field(newFieldDefinition() - .name("id") - .type(GraphQLString)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("user") - .type(objectType)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "User" : objectType, - "Node" : interfaceType, - "String": GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "interface type is attached (not detached)" - detached.empty - } - - def "interface type field types are followed"() { - given: "a custom type" - def customType = newObject() - .name("CustomType") - .field(newFieldDefinition() - .name("value") - .type(GraphQLInt)) - .build() - - and: "an interface type with field of custom type" - def interfaceType = newInterface() - .name("Node") - .field(newFieldDefinition() - .name("custom") - .type(customType)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("node") - .type(interfaceType)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "Node" : interfaceType, - "CustomType": customType, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "custom type is attached (not detached)" - detached.empty - } - - def "interface type field argument types are followed"() { - given: "an input type" - def inputType = newInputObject() - .name("InputType") - .field(newInputObjectField() - .name("value") - .type(GraphQLInt)) - .build() - - and: "an interface type with field that has argument of input type" - def interfaceType = newInterface() - .name("Searchable") - .field(newFieldDefinition() - .name("search") - .type(GraphQLString) - .argument(newArgument() - .name("filter") - .type(inputType))) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("searchable") - .type(interfaceType)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "Searchable": interfaceType, - "InputType" : inputType, - "String" : GraphQLString, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "input type is attached (not detached)" - detached.empty - } - - def "interface extending interface is followed"() { - given: "a base interface" - def baseInterface = newInterface() - .name("BaseInterface") - .field(newFieldDefinition() - .name("id") - .type(GraphQLString)) - .build() - - and: "an interface extending the base" - def childInterface = newInterface() - .name("ChildInterface") - .withInterface(baseInterface) - .field(newFieldDefinition() - .name("id") - .type(GraphQLString)) - .field(newFieldDefinition() - .name("name") - .type(GraphQLString)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("child") - .type(childInterface)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "ChildInterface": childInterface, - "BaseInterface" : baseInterface, - "String" : GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "base interface is attached (not detached)" - detached.empty - } - - def "union member types are followed"() { - given: "union member types" - def typeA = newObject() - .name("TypeA") - .field(newFieldDefinition() - .name("a") - .type(GraphQLString)) - .build() - - def typeB = newObject() - .name("TypeB") - .field(newFieldDefinition() - .name("b") - .type(GraphQLInt)) - .build() - - and: "a union type" - def unionType = newUnionType() - .name("SearchResult") - .possibleType(typeA) - .possibleType(typeB) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("search") - .type(unionType)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "SearchResult": unionType, - "TypeA" : typeA, - "TypeB" : typeB, - "String" : GraphQLString, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "all union member types are attached (not detached)" - detached.empty - } - - def "input object field types are followed"() { - given: "a nested input type" - def nestedInput = newInputObject() - .name("NestedInput") - .field(newInputObjectField() - .name("value") - .type(GraphQLInt)) - .build() - - and: "an input type with field of nested input type" - def inputType = newInputObject() - .name("InputType") - .field(newInputObjectField() - .name("nested") - .type(nestedInput)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("search") - .type(GraphQLString) - .argument(newArgument() - .name("input") - .type(inputType))) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "InputType" : inputType, - "NestedInput": nestedInput, - "String" : GraphQLString, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "nested input type is attached (not detached)" - detached.empty - } - - def "wrapped types (NonNull, List) are properly unwrapped"() { - given: "a custom type" - def customType = newObject() - .name("CustomType") - .field(newFieldDefinition() - .name("id") - .type(GraphQLInt)) - .build() - - and: "a query type with wrapped field type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("items") - .type(nonNull(list(nonNull(customType))))) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "CustomType": customType, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "custom type is attached despite wrapping (not detached)" - detached.empty - } - - def "type used only in directive argument is attached"() { - given: "a custom scalar" - def customScalar = newScalar() - .name("CustomScalar") - .coercing(GraphQLString.getCoercing()) - .build() - - and: "a directive using the custom scalar" - def directive = newDirective() - .name("customDirective") - .validLocation(Introspection.DirectiveLocation.FIELD) - .argument(newArgument() - .name("value") - .type(customScalar)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "CustomScalar": customScalar, - "String" : GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, [directive]) - - then: "custom scalar is attached (not detached)" - detached.empty - } - - def "directive with multiple arguments attaches all argument types"() { - given: "custom input types" - def inputTypeA = newInputObject() - .name("InputTypeA") - .field(newInputObjectField() - .name("value") - .type(GraphQLString)) - .build() - - def inputTypeB = newInputObject() - .name("InputTypeB") - .field(newInputObjectField() - .name("value") - .type(GraphQLString)) - .build() - - and: "a directive using both input types" - def directive = newDirective() - .name("multiArgDirective") - .validLocation(Introspection.DirectiveLocation.FIELD) - .argument(newArgument() - .name("argA") - .type(inputTypeA)) - .argument(newArgument() - .name("argB") - .type(inputTypeB)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "InputTypeA": inputTypeA, - "InputTypeB": inputTypeB, - "String" : GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, [directive]) - - then: "all directive argument types are attached (not detached)" - detached.empty - } - - def "empty schema has no detached types"() { - given: "a minimal query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "type map with only Query and String" - def typeMap = [ - "Query" : queryType, - "String": GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "no detached types" - detached.empty - } - - def "all types attached returns empty set"() { - given: "multiple types all connected" - def typeA = newObject() - .name("TypeA") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def typeB = newObject() - .name("TypeB") - .field(newFieldDefinition() - .name("value") - .type(GraphQLInt)) - .build() - - and: "a query type referencing both" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("a") - .type(typeA)) - .field(newFieldDefinition() - .name("b") - .type(typeB)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "TypeA" : typeA, - "TypeB" : typeB, - "String": GraphQLString, - "Int" : GraphQLInt - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "no detached types" - detached.empty - } - - def "all types detached except roots returns correct set"() { - given: "multiple detached types" - def detachedA = newObject() - .name("DetachedA") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def detachedB = newObject() - .name("DetachedB") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def detachedC = newObject() - .name("DetachedC") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "DetachedA": detachedA, - "DetachedB": detachedB, - "DetachedC": detachedC, - "String" : GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "all non-root types are detached" - detached*.name as Set == ["DetachedA", "DetachedB", "DetachedC"] as Set - } - - def "circular type references don't cause infinite loop"() { - given: "two types that reference each other" - def typeA = newObject() - .name("TypeA") - .field(newFieldDefinition() - .name("id") - .type(GraphQLString)) - .build() - - def typeB = newObject() - .name("TypeB") - .field(newFieldDefinition() - .name("id") - .type(GraphQLString)) - .build() - - // Add circular references - typeA = typeA.transform { builder -> - builder.field(newFieldDefinition() - .name("b") - .type(typeB)) - } - - typeB = typeB.transform { builder -> - builder.field(newFieldDefinition() - .name("a") - .type(typeA)) - } - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("a") - .type(typeA)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "TypeA" : typeA, - "TypeB" : typeB, - "String": GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "no error and both types are attached" - detached.empty - } - - def "complex mixed scenario with attached and detached types"() { - given: "attached types" - def attachedObject = newObject() - .name("AttachedObject") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def attachedEnum = newEnum() - .name("AttachedEnum") - .value(newEnumValueDefinition().name("VALUE1").value("VALUE1").build()) - .build() - - and: "detached types" - def detachedObject = newObject() - .name("DetachedObject") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def detachedScalar = newScalar() - .name("DetachedScalar") - .coercing(GraphQLString.getCoercing()) - .build() - - and: "a query type referencing only attached types" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("object") - .type(attachedObject)) - .field(newFieldDefinition() - .name("enum") - .type(attachedEnum)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "AttachedObject" : attachedObject, - "AttachedEnum" : attachedEnum, - "DetachedObject" : detachedObject, - "DetachedScalar" : detachedScalar, - "String" : GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "only detached types are returned" - detached*.name as Set == ["DetachedObject", "DetachedScalar"] as Set - } - - def "type reachable through multiple paths is still attached"() { - given: "a shared type" - def sharedType = newObject() - .name("SharedType") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "a query type with multiple paths to shared type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("path1") - .type(sharedType)) - .field(newFieldDefinition() - .name("path2") - .type(sharedType)) - .field(newFieldDefinition() - .name("path3") - .type(list(sharedType))) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "SharedType": sharedType, - "String" : GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "shared type is attached (visited once, not multiple times)" - detached.empty - } - - def "type reachable from directive but not roots is still attached"() { - given: "a type used only in directive" - def directiveOnlyType = newInputObject() - .name("DirectiveOnlyType") - .field(newInputObjectField() - .name("value") - .type(GraphQLString)) - .build() - - and: "a directive using this type" - def directive = newDirective() - .name("customDirective") - .validLocation(Introspection.DirectiveLocation.FIELD) - .argument(newArgument() - .name("config") - .type(directiveOnlyType)) - .build() - - and: "a detached type" - def detachedType = newObject() - .name("DetachedType") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "DirectiveOnlyType": directiveOnlyType, - "DetachedType" : detachedType, - "String" : GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, [directive]) - - then: "directive type is attached, but truly detached type is not" - detached*.name as Set == ["DetachedType"] as Set - } - - def "deep nesting with wrapped types is properly traversed"() { - given: "deeply nested types" - def level3Type = newObject() - .name("Level3") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - def level2Type = newObject() - .name("Level2") - .field(newFieldDefinition() - .name("level3") - .type(nonNull(list(level3Type)))) - .build() - - def level1Type = newObject() - .name("Level1") - .field(newFieldDefinition() - .name("level2") - .type(list(nonNull(level2Type)))) - .build() - - and: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("level1") - .type(nonNull(level1Type))) - .build() - - and: "type map" - def typeMap = [ - "Query" : queryType, - "Level1": level1Type, - "Level2": level2Type, - "Level3": level3Type, - "String": GraphQLString - ] - - when: "finding detached types" - def detached = FindDetachedTypes.findDetachedTypes(typeMap, queryType, null, null, []) - - then: "all nested types are attached" - detached.empty - } -} From 7cf12cd46e130ead9354bb7df51819dbc1ecaab3 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Fri, 23 Jan 2026 08:37:30 +1000 Subject: [PATCH 15/25] Remove phase separator comments from FastBuilder tests Clean up test files by removing development phase comments like "==================== Phase X: ... ====================" that were used during incremental implementation. --- .../FastBuilderComparisonComplexTest.groovy | 4 ---- .../FastBuilderComparisonMigratedTest.groovy | 2 -- .../schema/FastBuilderComparisonTest.groovy | 6 ------ .../FastBuilderComparisonTypeRefTest.groovy | 16 ---------------- .../groovy/graphql/schema/FastBuilderTest.groovy | 16 ---------------- 5 files changed, 44 deletions(-) diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonComplexTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonComplexTest.groovy index 54164f054..808fd7d02 100644 --- a/src/test/groovy/graphql/schema/FastBuilderComparisonComplexTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonComplexTest.groovy @@ -27,8 +27,6 @@ import static graphql.schema.GraphQLUnionType.newUnionType */ class FastBuilderComparisonComplexTest extends FastBuilderComparisonTest { - // ==================== Complex Schemas ==================== - def "schema with all GraphQL type kinds matches between FastBuilder and standard builder"() { given: "SDL with all type kinds" def sdl = """ @@ -410,8 +408,6 @@ class FastBuilderComparisonComplexTest extends FastBuilderComparisonTest { assertSchemasEquivalent(fastSchema, standardSchema) } - // ==================== Directives ==================== - def "schema with custom directives matches between FastBuilder and standard builder"() { given: "SDL with custom directives" def sdl = """ diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonMigratedTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonMigratedTest.groovy index 7a85787c7..afad61b65 100644 --- a/src/test/groovy/graphql/schema/FastBuilderComparisonMigratedTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonMigratedTest.groovy @@ -15,8 +15,6 @@ import static graphql.schema.GraphQLScalarType.newScalar */ class FastBuilderComparisonMigratedTest extends FastBuilderComparisonTest { - // ==================== Migrated Tests ==================== - def "scalar type schema matches standard builder"() { given: "SDL for a schema with custom scalar" def sdl = """ diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy index b6cd20196..92893e025 100644 --- a/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy @@ -25,8 +25,6 @@ import static graphql.schema.GraphQLObjectType.newObject */ class FastBuilderComparisonTest extends Specification { - // ==================== Helper Functions ==================== - /** * Builds a schema from SDL using the standard path: * SDL → SchemaParser → SchemaGenerator → standard Builder @@ -78,8 +76,6 @@ class FastBuilderComparisonTest extends Specification { return builder.build() } - // ==================== Assertion Helpers ==================== - /** * Built-in scalar type names that may differ between FastBuilder and standard Builder. */ @@ -164,8 +160,6 @@ class FastBuilderComparisonTest extends Specification { } } - // ==================== Smoke Test ==================== - def "trivial schema with one String field matches between FastBuilder and standard builder"() { given: "SDL for a trivial schema" def sdl = """ diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonTypeRefTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonTypeRefTest.groovy index 7c44f17f7..4d733b7d8 100644 --- a/src/test/groovy/graphql/schema/FastBuilderComparisonTypeRefTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonTypeRefTest.groovy @@ -29,8 +29,6 @@ import static graphql.schema.GraphQLTypeReference.typeRef */ class FastBuilderComparisonTypeRefTest extends FastBuilderComparisonTest { - // ==================== Object Type with Type Reference Fields ==================== - def "object type with type reference field resolves correctly"() { given: "SDL with object type referencing another object" def sdl = """ @@ -205,8 +203,6 @@ class FastBuilderComparisonTypeRefTest extends FastBuilderComparisonTest { searchField.getArgument("filter").getType() == filterInput } - // ==================== Interface Type with Type Reference Fields ==================== - def "interface type with type reference field resolves correctly"() { given: "SDL with interface referencing object type" def sdl = """ @@ -316,8 +312,6 @@ class FastBuilderComparisonTypeRefTest extends FastBuilderComparisonTest { resolvedInterface.getFieldDefinition("search").getArgument("filter").getType() == filterInput } - // ==================== Union Type with Type Reference Members ==================== - def "union type with type reference members resolves correctly"() { given: "SDL with union of object types" def sdl = """ @@ -381,8 +375,6 @@ class FastBuilderComparisonTypeRefTest extends FastBuilderComparisonTest { resolvedPet.types[1] in [catType, dogType] } - // ==================== Input Object with Type Reference Fields ==================== - def "input object with type reference field resolves correctly"() { given: "SDL with input object referencing custom scalar" def sdl = """ @@ -497,8 +489,6 @@ class FastBuilderComparisonTypeRefTest extends FastBuilderComparisonTest { resolvedUser.getField("address").getType() == addressInput } - // ==================== Directive Arguments with Type References ==================== - def "directive argument with type reference resolves correctly"() { given: "SDL with directive referencing custom scalar" def sdl = """ @@ -647,8 +637,6 @@ class FastBuilderComparisonTypeRefTest extends FastBuilderComparisonTest { resolvedDirective.getArgument("settings").getType() == configInput } - // ==================== Applied Directives with Type References ==================== - def "schema applied directive with type reference argument resolves correctly"() { given: "SDL with schema-level applied directive" def sdl = """ @@ -809,8 +797,6 @@ class FastBuilderComparisonTypeRefTest extends FastBuilderComparisonTest { resolvedApplied.getArgument("info").getType() == GraphQLString } - // ==================== Nested Type References ==================== - def "nested type references with NonNull and List resolve correctly"() { given: "SDL with deeply nested type wrappers" def sdl = """ @@ -910,8 +896,6 @@ class FastBuilderComparisonTypeRefTest extends FastBuilderComparisonTest { resolvedPerson.getFieldDefinition("friend").getType() == personType } - // ==================== Complex Schema with Multiple Type References ==================== - def "complex schema with multiple type references resolves correctly"() { given: "SDL with many interconnected types" def sdl = """ diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index 8ccc4a72b..84f8d5c08 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -157,8 +157,6 @@ class FastBuilderTest extends Specification { schema.getType("Scalar2") != null } - // ==================== Phase 2: Directives with Scalar Arguments ==================== - def "directive with type reference argument resolves correctly"() { given: "a custom scalar" def customScalar = newScalar() @@ -415,8 +413,6 @@ class FastBuilderTest extends Specification { resolvedDirective.getArgument("msg").getType() == GraphQLString } - // ==================== Phase 3: Enumeration Types ==================== - def "enum type can be added to schema"() { given: "an enum type" def statusEnum = newEnum() @@ -488,8 +484,6 @@ class FastBuilderTest extends Specification { resolvedDirective.getArgument("level").getType() == levelEnum } - // ==================== Phase 4: Input Object Types ==================== - def "input object type can be added to schema"() { given: "an input object type" def inputType = newInputObject() @@ -735,8 +729,6 @@ class FastBuilderTest extends Specification { resolvedDirective.getArgument("settings").getType() == configInput } - // ==================== Phase 5: Applied Directives ==================== - def "schema applied directive with type reference argument resolves correctly"() { given: "a custom scalar for directive argument" def configScalar = newScalar() @@ -925,8 +917,6 @@ class FastBuilderTest extends Specification { schema.getSchemaAppliedDirective("dir2") != null } - // ==================== Phase 6: Object Types ==================== - def "object type field with type reference resolves correctly"() { given: "a custom object type" def personType = newObject() @@ -1245,8 +1235,6 @@ class FastBuilderTest extends Specification { thrown(AssertException) } - // ==================== Phase 7: Interface Types ==================== - def "interface type can be added to schema"() { given: "an interface type" def nodeInterface = GraphQLInterfaceType.newInterface() @@ -1530,8 +1518,6 @@ class FastBuilderTest extends Specification { resolvedApplied.getArgument("info").getType() == metaScalar } - // ==================== Phase 8: Union Types ==================== - def "union type can be added to schema"() { given: "possible types for union" def catType = newObject() @@ -1761,8 +1747,6 @@ class FastBuilderTest extends Specification { resolvedApplied.getArgument("info").getType() == metaScalar } - // ==================== Phase 9: Validation and Edge Cases ==================== - def "withValidation(false) allows schema without type resolver"() { given: "an interface without type resolver" def nodeInterface = GraphQLInterfaceType.newInterface() From c4463049e75a7b55f763e1bc78c46e08459ee538 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Fri, 23 Jan 2026 08:42:12 +1000 Subject: [PATCH 16/25] Fix FastBuilder Javadoc comments - Fix reference to non-existent addAdditionalType(s) method - Correct additionalTypes documentation to reflect actual behavior - Simplify type unwrapping code --- src/main/java/graphql/schema/GraphQLSchema.java | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index c874ef280..09c4773aa 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -1084,7 +1084,7 @@ private GraphQLSchema validateSchema(GraphQLSchema graphQLSchema) { *

Use FastBuilder when: *

    *
  • Building large schemas (500+ types) where construction time and memory are measurable
  • - *
  • All types are known without traversal and can be added explicitly with addAdditionalType(s)
  • + *
  • All types are known without traversal and can be added explicitly with {@link #addType} or {@link #addTypes}
  • *
  • There's no need to clear/reset the builder state midstream.
  • *
  • The code registry builder is complete and available when FastBuilder is constructed
  • *
@@ -1147,8 +1147,7 @@ public FastBuilder(GraphQLCodeRegistry.Builder codeRegistryBuilder, /** * Adds a type to the schema. The type must be a named type (not a wrapper like List or NonNull). - * A type added by this method will be included in {@link GraphQLSchema#getAdditionalTypes()} - * only when it is not reachable from the schema root types. + * All non-root types added via this method will be included in {@link GraphQLSchema#getAdditionalTypes()}. * * @param type the type to add * @return this builder for chaining @@ -1159,8 +1158,7 @@ public FastBuilder addType(GraphQLType type) { } // Unwrap to named type - GraphQLUnmodifiedType unwrapped = GraphQLTypeUtil.unwrapAll(type); - GraphQLNamedType namedType = (GraphQLNamedType) unwrapped; + GraphQLUnmodifiedType namedType = GraphQLTypeUtil.unwrapAll(type); String name = namedType.getName(); // Enforce uniqueness by name @@ -1203,8 +1201,7 @@ public FastBuilder addType(GraphQLType type) { /** * Adds multiple types to the schema. - * A type added by this method will be included in {@link GraphQLSchema#getAdditionalTypes()} - * only when it is not reachable from the schema root types. + * All non-root types added via this method will be included in {@link GraphQLSchema#getAdditionalTypes()}. * * @param types the types to add * @return this builder for chaining From 16a8fc2497dc475db21d37b58f195a80cf396529 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Fri, 23 Jan 2026 08:46:58 +1000 Subject: [PATCH 17/25] Change FastBuilder.addType to accept GraphQLNamedType - addType() and addTypes() now take GraphQLNamedType instead of GraphQLType - Remove unnecessary unwrapping logic since only named types are accepted - Remove null checks from builder methods (fail fast on null) - Update FastSchemaGenerator to use GraphQLNamedType - Update FastBuilderComparisonTest to use GraphQLNamedType --- .../java/graphql/schema/GraphQLSchema.java | 67 +++++++------------ .../schema/idl/FastSchemaGenerator.java | 13 ++-- .../schema/FastBuilderComparisonTest.groovy | 2 +- 3 files changed, 31 insertions(+), 51 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 09c4773aa..42ca923d4 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -1088,7 +1088,7 @@ private GraphQLSchema validateSchema(GraphQLSchema graphQLSchema) { *
  • There's no need to clear/reset the builder state midstream.
  • *
  • The code registry builder is complete and available when FastBuilder is constructed
  • * - * FastBuilder also can optionally skip schema validation, which can save time and + * FastBuilder also can optionally skip schema validation, which can save time and * memory for large schemas that have been previously validated (eg, in build tool chains). * * @see GraphQLSchema.Builder for standard schema construction @@ -1146,24 +1146,19 @@ public FastBuilder(GraphQLCodeRegistry.Builder codeRegistryBuilder, } /** - * Adds a type to the schema. The type must be a named type (not a wrapper like List or NonNull). + * Adds a named type to the schema. * All non-root types added via this method will be included in {@link GraphQLSchema#getAdditionalTypes()}. * - * @param type the type to add + * @param type the named type to add * @return this builder for chaining */ - public FastBuilder addType(GraphQLType type) { - if (type == null) { - return this; - } + public FastBuilder addType(GraphQLNamedType type) { - // Unwrap to named type - GraphQLUnmodifiedType namedType = GraphQLTypeUtil.unwrapAll(type); - String name = namedType.getName(); + String name = type.getName(); // Enforce uniqueness by name GraphQLNamedType existing = typeMap.get(name); - if (existing != null && existing != namedType) { + if (existing != null && existing != type) { throw new AssertException(String.format("Type '%s' already exists with a different instance", name)); } @@ -1173,14 +1168,14 @@ public FastBuilder addType(GraphQLType type) { } // Insert into typeMap - typeMap.put(name, namedType); + typeMap.put(name, type); // Shallow scan via ShallowTypeRefCollector (also tracks interface implementations) - shallowTypeRefCollector.handleTypeDef(namedType); + shallowTypeRefCollector.handleTypeDef(type); // For interface types, wire type resolver if present - if (namedType instanceof GraphQLInterfaceType) { - GraphQLInterfaceType interfaceType = (GraphQLInterfaceType) namedType; + if (type instanceof GraphQLInterfaceType) { + GraphQLInterfaceType interfaceType = (GraphQLInterfaceType) type; TypeResolver resolver = interfaceType.getTypeResolver(); if (resolver != null) { codeRegistryBuilder.typeResolverIfAbsent(interfaceType, resolver); @@ -1188,8 +1183,8 @@ public FastBuilder addType(GraphQLType type) { } // For union types, wire type resolver if present - if (namedType instanceof GraphQLUnionType) { - GraphQLUnionType unionType = (GraphQLUnionType) namedType; + if (type instanceof GraphQLUnionType) { + GraphQLUnionType unionType = (GraphQLUnionType) type; TypeResolver resolver = unionType.getTypeResolver(); if (resolver != null) { codeRegistryBuilder.typeResolverIfAbsent(unionType, resolver); @@ -1200,16 +1195,14 @@ public FastBuilder addType(GraphQLType type) { } /** - * Adds multiple types to the schema. + * Adds multiple named types to the schema. * All non-root types added via this method will be included in {@link GraphQLSchema#getAdditionalTypes()}. * - * @param types the types to add + * @param types the named types to add * @return this builder for chaining */ - public FastBuilder addTypes(Collection types) { - if (types != null) { - types.forEach(this::addType); - } + public FastBuilder addTypes(Collection types) { + types.forEach(this::addType); return this; } @@ -1220,10 +1213,6 @@ public FastBuilder addTypes(Collection types) { * @return this builder for chaining */ public FastBuilder additionalDirective(GraphQLDirective directive) { - if (directive == null) { - return this; - } - String name = directive.getName(); GraphQLDirective existing = directiveMap.get(name); if (existing != null && existing != directive) { @@ -1245,9 +1234,7 @@ public FastBuilder additionalDirective(GraphQLDirective directive) { * @return this builder for chaining */ public FastBuilder additionalDirectives(Collection directives) { - if (directives != null) { - directives.forEach(this::additionalDirective); - } + directives.forEach(this::additionalDirective); return this; } @@ -1258,9 +1245,7 @@ public FastBuilder additionalDirectives(Collection d * @return this builder for chaining */ public FastBuilder withSchemaDirective(GraphQLDirective directive) { - if (directive != null) { - schemaDirectives.add(directive); - } + schemaDirectives.add(directive); return this; } @@ -1271,9 +1256,7 @@ public FastBuilder withSchemaDirective(GraphQLDirective directive) { * @return this builder for chaining */ public FastBuilder withSchemaDirectives(Collection directives) { - if (directives != null) { - schemaDirectives.addAll(directives); - } + schemaDirectives.addAll(directives); return this; } @@ -1284,11 +1267,9 @@ public FastBuilder withSchemaDirectives(Collection d * @return this builder for chaining */ public FastBuilder withSchemaAppliedDirective(GraphQLAppliedDirective applied) { - if (applied != null) { - schemaAppliedDirectives.add(applied); - // Scan applied directive arguments for type references - shallowTypeRefCollector.scanAppliedDirectives(singletonList(applied)); - } + schemaAppliedDirectives.add(applied); + // Scan applied directive arguments for type references + shallowTypeRefCollector.scanAppliedDirectives(singletonList(applied)); return this; } @@ -1299,9 +1280,7 @@ public FastBuilder withSchemaAppliedDirective(GraphQLAppliedDirective applied) { * @return this builder for chaining */ public FastBuilder withSchemaAppliedDirectives(Collection appliedList) { - if (appliedList != null) { - schemaAppliedDirectives.addAll(appliedList); - } + schemaAppliedDirectives.addAll(appliedList); return this; } diff --git a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java index 6efef74e9..c93652ab3 100644 --- a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java +++ b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java @@ -7,10 +7,9 @@ import graphql.schema.GraphQLNamedType; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLSchema; -import graphql.schema.GraphQLType; - import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import static graphql.schema.idl.SchemaGeneratorHelper.buildDescription; @@ -81,8 +80,10 @@ private GraphQLSchema makeExecutableSchemaImpl(ImmutableTypeDefinitionRegistry t // Build the code registry GraphQLCodeRegistry codeRegistry = buildCtx.getCodeRegistry().build(); - // Extract operation types by name from built types - Set allBuiltTypes = buildCtx.getTypes(); + // Extract operation types by name from built types (all types from buildCtx are named types) + Set allBuiltTypes = buildCtx.getTypes().stream() + .map(t -> (GraphQLNamedType) t) + .collect(Collectors.toSet()); // Get the actual type names from operationTypeDefinitions, defaulting to standard names String queryTypeName = getOperationTypeName(operationTypeDefinitions, "query", "Query"); @@ -136,8 +137,8 @@ private String getOperationTypeName(Map operati return defaultTypeName; } - private GraphQLObjectType findOperationType(Set types, String typeName) { - for (GraphQLType type : types) { + private GraphQLObjectType findOperationType(Set types, String typeName) { + for (GraphQLNamedType type : types) { if (type instanceof GraphQLObjectType) { GraphQLObjectType objectType = (GraphQLObjectType) type; if (objectType.getName().equals(typeName)) { diff --git a/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy index 92893e025..bcd45c431 100644 --- a/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy @@ -53,7 +53,7 @@ class FastBuilderComparisonTest extends Specification { GraphQLObjectType queryType, GraphQLObjectType mutationType = null, GraphQLObjectType subscriptionType = null, - List additionalTypes = [], + List additionalTypes = [], List additionalDirectives = [], GraphQLCodeRegistry.Builder codeRegistry = null ) { From d430e66c0c0a5a6fa98aba7a25e864efe7652f76 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Fri, 23 Jan 2026 16:49:22 -0500 Subject: [PATCH 18/25] Fixes * Add all introspective types * Add scalar types needed by introspective types * Add regression tests that missed the above * Remove tests invalidated by recent code changes --- .../java/graphql/schema/GraphQLSchema.java | 17 +++ .../schema/idl/FastSchemaGenerator.java | 10 ++ .../graphql/schema/FastBuilderTest.groovy | 48 ------ .../schema/idl/FastSchemaGeneratorTest.groovy | 143 ++++++++++++++++++ 4 files changed, 170 insertions(+), 48 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 42ca923d4..2aa998509 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -7,6 +7,7 @@ import graphql.Assert; import graphql.AssertException; import graphql.Directives; +import graphql.Scalars; import graphql.DirectivesUtil; import graphql.ExperimentalApi; import graphql.Internal; @@ -1135,6 +1136,22 @@ public FastBuilder(GraphQLCodeRegistry.Builder codeRegistryBuilder, // Add introspection code to the registry Introspection.addCodeForIntrospectionTypes(codeRegistryBuilder); + // Add introspection types to the type map + // These must be present for introspection queries to work correctly + addType(Introspection.__Schema); + addType(Introspection.__Type); + addType(Introspection.__Field); + addType(Introspection.__InputValue); + addType(Introspection.__EnumValue); + addType(Introspection.__Directive); + addType(Introspection.__TypeKind); + addType(Introspection.__DirectiveLocation); + + // Add String and Boolean scalars required by introspection types + // (e.g., __Type.name returns String, __Field.isDeprecated returns Boolean) + addType(Scalars.GraphQLString); + addType(Scalars.GraphQLBoolean); + // Add root types addType(queryType); if (mutationType != null) { diff --git a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java index c93652ab3..30932fc12 100644 --- a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java +++ b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java @@ -1,5 +1,6 @@ package graphql.schema.idl; +import graphql.GraphQLError; import graphql.Internal; import graphql.language.OperationTypeDefinition; import graphql.schema.GraphQLCodeRegistry; @@ -7,6 +8,8 @@ import graphql.schema.GraphQLNamedType; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLSchema; +import graphql.schema.idl.errors.SchemaProblem; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -20,6 +23,7 @@ @Internal public class FastSchemaGenerator { + private final SchemaTypeChecker typeChecker = new SchemaTypeChecker(); private final SchemaGeneratorHelper schemaGeneratorHelper = new SchemaGeneratorHelper(); /** @@ -51,6 +55,12 @@ public GraphQLSchema makeExecutableSchema(SchemaGenerator.Options options, TypeD // Use immutable registry for faster operations ImmutableTypeDefinitionRegistry fasterImmutableRegistry = typeRegistryCopy.readOnly(); + // Check type registry for errors + List errors = typeChecker.checkTypeRegistry(fasterImmutableRegistry, wiring); + if (!errors.isEmpty()) { + throw new SchemaProblem(errors); + } + Map operationTypeDefinitions = SchemaExtensionsChecker.gatherOperationDefs(fasterImmutableRegistry); return makeExecutableSchemaImpl(fasterImmutableRegistry, wiring, operationTypeDefinitions, options); diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index 84f8d5c08..ca8a690cd 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -80,27 +80,6 @@ class FastBuilderTest extends Specification { schema.getType("MyScalar") != null } - def "null type is safely ignored"() { - given: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - and: "code registry" - def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() - - when: "adding null type" - def schema = new GraphQLSchema.FastBuilder(codeRegistry, queryType, null, null) - .addType(null) - .build() - - then: "no error" - schema.queryType.name == "Query" - } - def "query type is required"() { when: "creating FastBuilder with null query type" new GraphQLSchema.FastBuilder(GraphQLCodeRegistry.newCodeRegistry(), null, null, null) @@ -2002,31 +1981,4 @@ class FastBuilderTest extends Specification { def searchField = schema.queryType.getFieldDefinition("search") searchField.getArgument("filter").getType() == filterInput } - - def "null types and directives are ignored"() { - given: "a query type" - def queryType = newObject() - .name("Query") - .field(newFieldDefinition() - .name("value") - .type(GraphQLString)) - .build() - - when: "adding null types and directives" - def schema = new GraphQLSchema.FastBuilder( - GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) - .addType(null) - .addTypes(null) - .additionalDirective(null) - .additionalDirectives(null) - .withSchemaDirective(null) - .withSchemaDirectives(null) - .withSchemaAppliedDirective(null) - .withSchemaAppliedDirectives(null) - .build() - - then: "no error and schema builds" - schema != null - schema.queryType.name == "Query" - } } diff --git a/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy b/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy index 70da878cc..63ec1576b 100644 --- a/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy +++ b/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy @@ -1,6 +1,7 @@ package graphql.schema.idl import graphql.schema.GraphQLSchema +import graphql.schema.idl.errors.SchemaProblem import spock.lang.Specification class FastSchemaGeneratorTest extends Specification { @@ -134,4 +135,146 @@ class FastSchemaGeneratorTest extends Specification { schema.getType("User") != null schema.getType("Post") != null } + + // Regression tests to ensure FastSchemaGenerator behaves like SchemaGenerator + + def "should throw SchemaProblem for missing type reference"() { + given: + def sdl = ''' + type Query { + user: UnknownType + } + ''' + + when: + new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + thrown(SchemaProblem) + } + + def "should throw SchemaProblem for duplicate field definitions"() { + given: + def sdl = ''' + type Query { + hello: String + hello: Int + } + ''' + + when: + new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + thrown(SchemaProblem) + } + + def "should throw SchemaProblem for invalid interface implementation"() { + given: + def sdl = ''' + type Query { + node: Node + } + + interface Node { + id: ID! + } + + type User implements Node { + name: String + } + ''' + + when: + new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + // User claims to implement Node but doesn't have the required 'id' field + thrown(SchemaProblem) + } + + def "should include introspection types in getAllTypesAsList"() { + given: + def sdl = ''' + type Query { + hello: String + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + def allTypeNames = schema.getAllTypesAsList().collect { it.name } as Set + + then: + // Introspection types must be present for introspection queries to work + allTypeNames.contains("__Schema") + allTypeNames.contains("__Type") + allTypeNames.contains("__Field") + allTypeNames.contains("__InputValue") + allTypeNames.contains("__EnumValue") + allTypeNames.contains("__Directive") + allTypeNames.contains("__TypeKind") + allTypeNames.contains("__DirectiveLocation") + } + + def "should include String and Boolean scalars in getAllTypesAsList"() { + given: + def sdl = ''' + type Query { + id: ID + } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + def allTypeNames = schema.getAllTypesAsList().collect { it.name } as Set + + then: + // String and Boolean are required by introspection types + // (__Type.name, __Field.name, etc. return String; __Field.isDeprecated returns Boolean) + allTypeNames.contains("String") + allTypeNames.contains("Boolean") + } + + def "introspection types should match standard SchemaGenerator"() { + given: + def sdl = ''' + type Query { + hello: String + } + ''' + def registry = new SchemaParser().parse(sdl) + + when: + def standardSchema = new SchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING) + def fastSchema = new FastSchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING) + + def standardTypeNames = standardSchema.getAllTypesAsList().collect { it.name } as Set + def fastTypeNames = fastSchema.getAllTypesAsList().collect { it.name } as Set + + then: + // FastBuilder should include all introspection types that standard builder includes + standardTypeNames.findAll { it.startsWith("__") }.each { introspectionType -> + assert fastTypeNames.contains(introspectionType) : "Missing introspection type: $introspectionType" + } + // FastBuilder should not contain introspection types not in standard builder + fastTypeNames.findAll { it.startsWith("__") }.each { introspectionType -> + assert standardTypeNames.contains(introspectionType) : "Extra introspection type: $introspectionType" + } + } } From a15baf6737d98b47fb03ed8e221ae2b9775bbfc3 Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Sun, 25 Jan 2026 12:55:09 -0800 Subject: [PATCH 19/25] Refactor built-in directive handling A reviewer noted that the dispirate ways that the classic builder and the new "fast" builder handled built-in directives created two parallel lists, which is a maintenance burden. This commit consolidates the lists. --- src/main/java/graphql/Directives.java | 30 ++++++++++++++++ .../java/graphql/schema/GraphQLSchema.java | 35 +++++++------------ 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/main/java/graphql/Directives.java b/src/main/java/graphql/Directives.java index 37f2b2855..00b4c61ef 100644 --- a/src/main/java/graphql/Directives.java +++ b/src/main/java/graphql/Directives.java @@ -7,6 +7,7 @@ import graphql.language.StringValue; import graphql.schema.GraphQLDirective; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import static graphql.Scalars.GraphQLBoolean; @@ -249,6 +250,35 @@ public class Directives { .definition(EXPERIMENTAL_DISABLE_ERROR_PROPAGATION_DIRECTIVE_DEFINITION) .build(); + /** + * Returns the directives that are included in a schema by default but can be removed + * by calling {@code clearDirectives()} on the builder. + * + * @return an unmodifiable list of default directives (include, skip) + */ + @Internal + public static List getDefaultDirectives() { + return List.of(IncludeDirective, SkipDirective); + } + + /** + * Returns the directives that are mandatory and will always be added to a schema, + * even after {@code clearDirectives()} is called on the builder. + * These are inherently part of the GraphQL spec. + * + * @return an unmodifiable list of mandatory directives + */ + @Internal + public static List getMandatoryDirectives() { + return List.of( + DeprecatedDirective, + SpecifiedByDirective, + OneOfDirective, + DeferDirective, + ExperimentalDisableErrorPropagationDirective + ); + } + private static Description createDescription(String s) { return new Description(s, null, false); } diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 2aa998509..adf897506 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -42,7 +42,6 @@ import static graphql.collect.ImmutableKit.nonNullCopyOf; import static graphql.schema.GraphqlTypeComparators.byNameAsc; import static graphql.schema.GraphqlTypeComparators.sortTypes; -import static java.util.Arrays.asList; import static java.util.Collections.singletonList; /** @@ -812,9 +811,10 @@ public static class Builder { private List extensionDefinitions; private String description; - // we default these in + // We initially add these default directives (e.g., include and skip), but these can be + // cleared by the user (unlike mandatory ones which are always re-added in buildImpl) private final Set additionalDirectives = new LinkedHashSet<>( - asList(Directives.IncludeDirective, Directives.SkipDirective) + Directives.getDefaultDirectives() ); private final Set additionalTypes = new LinkedHashSet<>(); private final List schemaDirectives = new ArrayList<>(); @@ -1031,13 +1031,8 @@ private GraphQLSchema buildImpl() { assertNotNull(additionalTypes, "additionalTypes can't be null"); assertNotNull(additionalDirectives, "additionalDirectives can't be null"); - // schemas built via the schema generator have the deprecated directive BUT we want it present for hand built - // schemas - it's inherently part of the spec! - addBuiltInDirective(Directives.DeprecatedDirective, additionalDirectives); - addBuiltInDirective(Directives.SpecifiedByDirective, additionalDirectives); - addBuiltInDirective(Directives.OneOfDirective, additionalDirectives); - addBuiltInDirective(Directives.DeferDirective, additionalDirectives); - addBuiltInDirective(Directives.ExperimentalDisableErrorPropagationDirective, additionalDirectives); + // Mandatory directives are always added, even after clearDirectives() - they're part of the spec + Directives.getMandatoryDirectives().forEach(d -> addBuiltInDirective(d, additionalDirectives)); // quick build - no traversing final GraphQLSchema partiallyBuiltSchema = new GraphQLSchema(this); @@ -1060,12 +1055,6 @@ private GraphQLSchema buildImpl() { return validateSchema(finalSchema); } - private void addBuiltInDirective(GraphQLDirective qlDirective, Set additionalDirectives1) { - if (additionalDirectives1.stream().noneMatch(d -> d.getName().equals(qlDirective.getName()))) { - additionalDirectives1.add(qlDirective); - } - } - private GraphQLSchema validateSchema(GraphQLSchema graphQLSchema) { Collection errors = new SchemaValidator().validateSchema(graphQLSchema); if (!errors.isEmpty()) { @@ -1073,6 +1062,12 @@ private GraphQLSchema validateSchema(GraphQLSchema graphQLSchema) { } return graphQLSchema; } + + private void addBuiltInDirective(GraphQLDirective qlDirective, Set additionalDirectives1) { + if (additionalDirectives1.stream().noneMatch(d -> d.getName().equals(qlDirective.getName()))) { + additionalDirectives1.add(qlDirective); + } + } } /** @@ -1383,12 +1378,8 @@ public GraphQLSchema build() { } private void addBuiltInDirectivesIfMissing() { - addDirectiveIfMissing(Directives.IncludeDirective); - addDirectiveIfMissing(Directives.SkipDirective); - addDirectiveIfMissing(Directives.DeprecatedDirective); - addDirectiveIfMissing(Directives.SpecifiedByDirective); - addDirectiveIfMissing(Directives.OneOfDirective); - addDirectiveIfMissing(Directives.DeferDirective); + Directives.getDefaultDirectives().forEach(this::addDirectiveIfMissing); + Directives.getMandatoryDirectives().forEach(this::addDirectiveIfMissing); } private void addDirectiveIfMissing(GraphQLDirective directive) { From eb8e24caa66acd546d9d6c5b9e23425056462025 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Wed, 28 Jan 2026 11:53:47 +1000 Subject: [PATCH 20/25] Fix withSchemaAppliedDirectives(Collection) not scanning for type references The batch method was adding applied directives directly without scanning their arguments for GraphQLTypeReference instances. This left unresolved type references in the built schema. Fix by delegating to the singular withSchemaAppliedDirective() method which already performs the scan, matching the pattern used by the standard Builder. Co-Authored-By: Claude Opus 4.5 --- .../java/graphql/schema/GraphQLSchema.java | 4 +- .../graphql/schema/FastBuilderTest.groovy | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index adf897506..f525a9213 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -1292,7 +1292,9 @@ public FastBuilder withSchemaAppliedDirective(GraphQLAppliedDirective applied) { * @return this builder for chaining */ public FastBuilder withSchemaAppliedDirectives(Collection appliedList) { - schemaAppliedDirectives.addAll(appliedList); + for (GraphQLAppliedDirective applied : appliedList) { + withSchemaAppliedDirective(applied); + } return this; } diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index ca8a690cd..2f0ea6915 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -896,6 +896,60 @@ class FastBuilderTest extends Specification { schema.getSchemaAppliedDirective("dir2") != null } + def "batch schema applied directives with type reference arguments resolve correctly"() { + given: "a custom scalar for directive arguments" + def configScalar = newScalar() + .name("ConfigValue") + .coercing(GraphQLString.getCoercing()) + .build() + + and: "a directive definition" + def directive = newDirective() + .name("config") + .validLocation(Introspection.DirectiveLocation.SCHEMA) + .argument(newArgument() + .name("value") + .type(configScalar)) + .build() + + and: "applied directives with type references added via batch method" + def applied1 = newAppliedDirective() + .name("config") + .argument(newAppliedArgument() + .name("value") + .type(typeRef("ConfigValue")) + .valueProgrammatic("test1")) + .build() + def applied2 = newAppliedDirective() + .name("config") + .argument(newAppliedArgument() + .name("value") + .type(typeRef("ConfigValue")) + .valueProgrammatic("test2")) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("value") + .type(GraphQLString)) + .build() + + when: "building with FastBuilder using batch withSchemaAppliedDirectives" + def schema = new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .addType(configScalar) + .additionalDirective(directive) + .withSchemaAppliedDirectives([applied1, applied2]) + .build() + + then: "both applied directive argument types are resolved" + def resolvedDirectives = schema.getSchemaAppliedDirectives("config") + resolvedDirectives.size() == 2 + resolvedDirectives.every { it.getArgument("value").getType() == configScalar } + } + def "object type field with type reference resolves correctly"() { given: "a custom object type" def personType = newObject() From 719f91f7caad4d1cf1ef446c125ada145682dabb Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Thu, 29 Jan 2026 09:25:07 -0800 Subject: [PATCH 21/25] Remove cruft Fixed a bug after resolving conflicts but forgot to add it to the merge commit. --- src/main/java/graphql/schema/GraphQLSchema.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index e8b6e2000..5ae7a60b0 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -1371,9 +1371,6 @@ public GraphQLSchema build() { // Step 2: Add built-in directives if missing Directives.BUILT_IN_DIRECTIVES.forEach(this::addDirectiveIfMissing); - - addBuiltInDirectivesIfMissing(); - // Step 3: Create schema via private constructor GraphQLSchema schema = new GraphQLSchema(this); From 14a31e402623da4a0455229d1115dc7bd8f88509 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Fri, 30 Jan 2026 07:34:58 +1000 Subject: [PATCH 22/25] Improve FastBuilder documentation and add @NullMarked - Remove unused Pattern import from Assert.java - Add @NullMarked annotation to ShallowTypeRefCollector - Expand FastBuilder class documentation with clear guidance on: - When to use FastBuilder (large schemas, known types, pre-validated) - When NOT to use FastBuilder (type discovery, type reuse, dynamic construction) - Key differences from standard Builder - Example usage code - Add mutation warnings to addType() and build() methods - Document type mutation behavior in ShallowTypeRefCollector.replaceTypes() Co-Authored-By: Claude Opus 4.5 --- src/main/java/graphql/Assert.java | 1 - .../java/graphql/schema/GraphQLSchema.java | 77 +++++++++++++++---- .../schema/ShallowTypeRefCollector.java | 8 ++ 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/main/java/graphql/Assert.java b/src/main/java/graphql/Assert.java index 54ca91784..b53a8eb4f 100644 --- a/src/main/java/graphql/Assert.java +++ b/src/main/java/graphql/Assert.java @@ -5,7 +5,6 @@ import java.util.Collection; import java.util.function.Supplier; -import java.util.regex.Pattern; import static java.lang.String.format; diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 5ae7a60b0..228616c34 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -1077,23 +1077,63 @@ private void addBuiltInDirective(GraphQLDirective qlDirective, Set Use FastBuilder when: + *

    When to use FastBuilder

    *
      - *
    • Building large schemas (500+ types) where construction time and memory are measurable
    • - *
    • All types are known without traversal and can be added explicitly with {@link #addType} or {@link #addTypes}
    • - *
    • There's no need to clear/reset the builder state midstream.
    • - *
    • The code registry builder is complete and available when FastBuilder is constructed
    • + *
    • Building large schemas where construction time and memory are measurable concerns
    • + *
    • All types are known upfront and can be added explicitly via {@link #addType} or {@link #addTypes}
    • + *
    • The code registry is complete and available when FastBuilder is constructed
    • + *
    • Schema has been previously validated (e.g., in a build pipeline) and validation can be skipped
    • *
    - * FastBuilder also can optionally skip schema validation, which can save time and - * memory for large schemas that have been previously validated (eg, in build tool chains). * - * @see GraphQLSchema.Builder for standard schema construction + *

    When NOT to use FastBuilder

    + *
      + *
    • Type discovery required: If you rely on automatic type discovery by traversing from + * root types (Query/Mutation/Subscription), use the standard {@link Builder} instead. + * FastBuilder requires ALL types to be explicitly added.
    • + *
    • Type reuse across schemas: FastBuilder mutates type objects during {@link #build()} + * to resolve {@link GraphQLTypeReference}s. The same type instances cannot be used to build + * multiple schemas. Create fresh type instances for each schema if needed.
    • + *
    • Dynamic schema construction: FastBuilder does not support clearing or resetting + * state. Each FastBuilder instance should be used exactly once.
    • + *
    • Schema transformation: For transforming existing schemas, use + * {@link GraphQLSchema#transform(Consumer)} or {@link Builder} instead.
    • + *
    + * + *

    Key differences from standard Builder

    + *
      + *
    • No automatic type discovery: You must add ALL types explicitly, including + * interface implementations that would normally be discovered via traversal.
    • + *
    • Type mutation: {@link GraphQLTypeReference} instances in added types are replaced + * in-place with actual types during build. This mutates the original type objects.
    • + *
    • additionalTypes semantic: {@link GraphQLSchema#getAdditionalTypes()} returns ALL + * non-root types (not just "detached" types as with standard Builder).
    • + *
    • Validation off by default: Enable with {@link #withValidation(boolean)} if needed.
    • + *
    + * + *

    Example usage

    + *
    {@code
    +     * GraphQLObjectType queryType = ...;
    +     * GraphQLObjectType mutationType = ...;
    +     * Set allTypes = ...;  // All types including interface implementations
    +     * Set directives = ...;
    +     *
    +     * GraphQLSchema schema = new GraphQLSchema.FastBuilder(
    +     *         GraphQLCodeRegistry.newCodeRegistry(),
    +     *         queryType,
    +     *         mutationType,
    +     *         null)  // no subscription
    +     *     .addTypes(allTypes)
    +     *     .additionalDirectives(directives)
    +     *     .withValidation(true)  // optional, off by default
    +     *     .build();
    +     * }
    + * + * @see GraphQLSchema.Builder for standard schema construction with automatic type discovery */ @ExperimentalApi @NullMarked @@ -1166,6 +1206,10 @@ public FastBuilder(GraphQLCodeRegistry.Builder codeRegistryBuilder, /** * Adds a named type to the schema. * All non-root types added via this method will be included in {@link GraphQLSchema#getAdditionalTypes()}. + *

    + * Warning: The type object will be mutated during {@link #build()} if it contains + * {@link GraphQLTypeReference} instances. Do not reuse the same type instance across + * multiple FastBuilder instances. * * @param type the named type to add * @return this builder for chaining @@ -1361,8 +1405,15 @@ public FastBuilder withValidation(boolean enabled) { /** * Builds the GraphQL schema. + *

    + * Warning: This method mutates the type and directive objects that were added to this + * builder. Any {@link GraphQLTypeReference} instances within those objects are replaced + * in-place with the actual resolved types. After calling this method, the added types + * should not be reused with another FastBuilder. * * @return the built schema + * @throws InvalidSchemaException if validation is enabled and the schema is invalid + * @throws AssertException if a type reference cannot be resolved */ public GraphQLSchema build() { // Step 1: Replace type references diff --git a/src/main/java/graphql/schema/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/ShallowTypeRefCollector.java index 140187f18..d597b63b2 100644 --- a/src/main/java/graphql/schema/ShallowTypeRefCollector.java +++ b/src/main/java/graphql/schema/ShallowTypeRefCollector.java @@ -4,6 +4,7 @@ import com.google.common.collect.ImmutableMap; import graphql.AssertException; import graphql.Internal; +import org.jspecify.annotations.NullMarked; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -18,6 +19,7 @@ * Also tracks interface-to-implementation relationships. */ @Internal +@NullMarked public class ShallowTypeRefCollector { // Replacement targets - no common supertype exists for the replacement-target classes, @@ -214,6 +216,12 @@ private boolean containsTypeReference(GraphQLType type) { /** * Replace all collected type references with actual types from typeMap. * After this call, no GraphQLTypeReference should remain in the schema. + *

    + * Important: This method mutates the type objects that were scanned via + * {@link #handleTypeDef(GraphQLNamedType)} and {@link #handleDirective(GraphQLDirective)}. + * The same type instances cannot be reused to build another schema after this method + * has been called. If you need to build multiple schemas with the same types, you must + * create new type instances for each schema. * * @param typeMap the map of type names to actual types * @throws graphql.AssertException if a referenced type is not found in typeMap From cd32b5cccb65d5fe7c1ccfa0cbbfab44a50f19a4 Mon Sep 17 00:00:00 2001 From: Andreas Marek Date: Fri, 30 Jan 2026 07:39:27 +1000 Subject: [PATCH 23/25] Remove redundant checks in assertValidName, mark param @Nullable - Mark assertValidName parameter as @Nullable since callers may pass null - Keep null check in assertValidName (required for @Nullable contract) - Remove redundant empty check that was duplicated in isValidName - Use String.valueOf() for null-safe error message formatting Co-Authored-By: Claude Opus 4.5 --- src/main/java/graphql/Assert.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/graphql/Assert.java b/src/main/java/graphql/Assert.java index b53a8eb4f..c4e79640b 100644 --- a/src/main/java/graphql/Assert.java +++ b/src/main/java/graphql/Assert.java @@ -231,11 +231,11 @@ public static void assertFalse(boolean condition, String msgFmt, Object arg1, Ob * * @return the name if valid, or AssertException if invalid. */ - public static String assertValidName(String name) { - if (name != null && !name.isEmpty() && isValidName(name)) { + public static String assertValidName(@Nullable String name) { + if (name != null && isValidName(name)) { return name; } - return throwAssert(invalidNameErrorMessage, name); + return throwAssert(invalidNameErrorMessage, String.valueOf(name)); } /** From 131f1699c67efc67782e979f79d603f8aed1c37e Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Fri, 30 Jan 2026 09:24:54 -0800 Subject: [PATCH 24/25] Promote FastSchemaGenerator to @ExperimentalApi with safer defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change from @Internal to @ExperimentalApi, making it part of the public API - Add @NullMarked and @Nullable annotations for null safety - Enable schema validation by default (previously skipped for performance) - Add withValidation option to SchemaGenerator.Options for explicit opt-out - Update documentation to reference FastBuilder limitations - Add tests for validation behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/benchmark/BuildSchemaBenchmark.java | 6 +- .../java/benchmark/CreateSchemaBenchmark.java | 5 +- .../schema/idl/FastSchemaGenerator.java | 35 ++++++----- .../graphql/schema/idl/SchemaGenerator.java | 42 ++++++++++++-- .../schema/idl/FastSchemaGeneratorTest.groovy | 58 ++++++++++++++++++- .../schema/idl/SchemaGeneratorTest.groovy | 16 ++++- 6 files changed, 136 insertions(+), 26 deletions(-) diff --git a/src/jmh/java/benchmark/BuildSchemaBenchmark.java b/src/jmh/java/benchmark/BuildSchemaBenchmark.java index de12731e6..8d32847ac 100644 --- a/src/jmh/java/benchmark/BuildSchemaBenchmark.java +++ b/src/jmh/java/benchmark/BuildSchemaBenchmark.java @@ -1,6 +1,5 @@ package benchmark; -import graphql.schema.GraphQLSchema; import graphql.schema.idl.FastSchemaGenerator; import graphql.schema.idl.RuntimeWiring; import graphql.schema.idl.SchemaGenerator; @@ -47,6 +46,9 @@ public void benchmarkBuildSchemaAvgTime(Blackhole blackhole) { @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MILLISECONDS) public void benchmarkBuildSchemaAvgTimeFast(Blackhole blackhole) { - blackhole.consume(new FastSchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING)); + blackhole.consume(new FastSchemaGenerator().makeExecutableSchema( + SchemaGenerator.Options.defaultOptions().withValidation(false), + registry, + RuntimeWiring.MOCKED_WIRING)); } } diff --git a/src/jmh/java/benchmark/CreateSchemaBenchmark.java b/src/jmh/java/benchmark/CreateSchemaBenchmark.java index ddbbd6fda..969f93c75 100644 --- a/src/jmh/java/benchmark/CreateSchemaBenchmark.java +++ b/src/jmh/java/benchmark/CreateSchemaBenchmark.java @@ -52,7 +52,10 @@ private static GraphQLSchema createSchema(String sdl) { private static GraphQLSchema createSchemaFast(String sdl) { TypeDefinitionRegistry registry = new SchemaParser().parse(sdl); - return new FastSchemaGenerator().makeExecutableSchema(registry, RuntimeWiring.MOCKED_WIRING); + return new FastSchemaGenerator().makeExecutableSchema( + SchemaGenerator.Options.defaultOptions().withValidation(false), + registry, + RuntimeWiring.MOCKED_WIRING); } @SuppressWarnings("InfiniteLoopStatement") diff --git a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java index 30932fc12..bc74c1332 100644 --- a/src/main/java/graphql/schema/idl/FastSchemaGenerator.java +++ b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java @@ -1,8 +1,10 @@ package graphql.schema.idl; +import graphql.ExperimentalApi; import graphql.GraphQLError; -import graphql.Internal; import graphql.language.OperationTypeDefinition; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import graphql.schema.GraphQLCodeRegistry; import graphql.schema.GraphQLDirective; import graphql.schema.GraphQLNamedType; @@ -17,10 +19,16 @@ import static graphql.schema.idl.SchemaGeneratorHelper.buildDescription; /** - * A schema generator that uses GraphQLSchema.FastBuilder for improved performance. - * This is intended for benchmarking and performance testing purposes. + * A schema generator that uses {@link GraphQLSchema.FastBuilder} to construct the schema. + * {@link GraphQLSchema.FastBuilder} has a number of important limitations, so please read + * its documentation carefully to understand if you should use this instead of the standard + * {@link SchemaGenerator}. + * + * @see GraphQLSchema.FastBuilder + * @see SchemaGenerator */ -@Internal +@ExperimentalApi +@NullMarked public class FastSchemaGenerator { private final SchemaTypeChecker typeChecker = new SchemaTypeChecker(); @@ -28,11 +36,10 @@ public class FastSchemaGenerator { /** * Creates an executable schema from a TypeDefinitionRegistry using FastBuilder. - * This method is optimized for performance and skips validation by default. * * @param typeRegistry the type definition registry * @param wiring the runtime wiring - * @return an executable schema + * @return a validated, executable schema */ public GraphQLSchema makeExecutableSchema(TypeDefinitionRegistry typeRegistry, RuntimeWiring wiring) { return makeExecutableSchema(SchemaGenerator.Options.defaultOptions(), typeRegistry, wiring); @@ -46,7 +53,9 @@ public GraphQLSchema makeExecutableSchema(TypeDefinitionRegistry typeRegistry, R * @param wiring the runtime wiring * @return an executable schema */ - public GraphQLSchema makeExecutableSchema(SchemaGenerator.Options options, TypeDefinitionRegistry typeRegistry, RuntimeWiring wiring) { + public GraphQLSchema makeExecutableSchema(SchemaGenerator.Options options, + TypeDefinitionRegistry typeRegistry, + RuntimeWiring wiring) { // Make a copy and add default directives TypeDefinitionRegistry typeRegistryCopy = new TypeDefinitionRegistry(); typeRegistryCopy.merge(typeRegistry); @@ -122,17 +131,15 @@ private GraphQLSchema makeExecutableSchemaImpl(ImmutableTypeDefinitionRegistry t // Add all directive definitions fastBuilder.additionalDirectives(additionalDirectives); - // Add schema description if present + // Add schema description and definition if present typeRegistry.schemaDefinition().ifPresent(schemaDefinition -> { String description = buildDescription(buildCtx, schemaDefinition, schemaDefinition.getDescription()); fastBuilder.description(description); + fastBuilder.definition(schemaDefinition); }); - // Add schema definition - fastBuilder.definition(typeRegistry.schemaDefinition().orElse(null)); - - // Disable validation for performance - fastBuilder.withValidation(false); + // Configure validation + fastBuilder.withValidation(options.isWithValidation()); return fastBuilder.build(); } @@ -147,7 +154,7 @@ private String getOperationTypeName(Map operati return defaultTypeName; } - private GraphQLObjectType findOperationType(Set types, String typeName) { + private @Nullable GraphQLObjectType findOperationType(Set types, String typeName) { for (GraphQLNamedType type : types) { if (type instanceof GraphQLObjectType) { GraphQLObjectType objectType = (GraphQLObjectType) type; diff --git a/src/main/java/graphql/schema/idl/SchemaGenerator.java b/src/main/java/graphql/schema/idl/SchemaGenerator.java index 05aeaf1dc..050458dc6 100644 --- a/src/main/java/graphql/schema/idl/SchemaGenerator.java +++ b/src/main/java/graphql/schema/idl/SchemaGenerator.java @@ -1,5 +1,6 @@ package graphql.schema.idl; +import graphql.ExperimentalApi; import graphql.GraphQLError; import graphql.PublicApi; import graphql.language.OperationTypeDefinition; @@ -98,6 +99,9 @@ public GraphQLSchema makeExecutableSchema(TypeDefinitionRegistry typeRegistry, R * @throws SchemaProblem if there are problems in assembling a schema such as missing type resolvers or no operations defined */ public GraphQLSchema makeExecutableSchema(Options options, TypeDefinitionRegistry typeRegistry, RuntimeWiring wiring) throws SchemaProblem { + if (!options.isWithValidation()) { + throw new IllegalArgumentException("SchemaGenerator does not support disabling validation. Use FastSchemaGenerator instead."); + } TypeDefinitionRegistry typeRegistryCopy = new TypeDefinitionRegistry(); typeRegistryCopy.merge(typeRegistry); @@ -167,11 +171,18 @@ public static class Options { private final boolean useCommentsAsDescription; private final boolean captureAstDefinitions; private final boolean useAppliedDirectivesOnly; + private final boolean withValidation; Options(boolean useCommentsAsDescription, boolean captureAstDefinitions, boolean useAppliedDirectivesOnly) { + this(useCommentsAsDescription, captureAstDefinitions, useAppliedDirectivesOnly, true); + } + + @ExperimentalApi + Options(boolean useCommentsAsDescription, boolean captureAstDefinitions, boolean useAppliedDirectivesOnly, boolean withValidation) { this.useCommentsAsDescription = useCommentsAsDescription; this.captureAstDefinitions = captureAstDefinitions; this.useAppliedDirectivesOnly = useAppliedDirectivesOnly; + this.withValidation = withValidation; } public boolean isUseCommentsAsDescription() { @@ -186,8 +197,13 @@ public boolean isUseAppliedDirectivesOnly() { return useAppliedDirectivesOnly; } + @ExperimentalApi + public boolean isWithValidation() { + return withValidation; + } + public static Options defaultOptions() { - return new Options(true, true, false); + return new Options(true, true, false, true); } /** @@ -200,7 +216,7 @@ public static Options defaultOptions() { * @return a new Options object */ public Options useCommentsAsDescriptions(boolean useCommentsAsDescription) { - return new Options(useCommentsAsDescription, captureAstDefinitions, useAppliedDirectivesOnly); + return new Options(useCommentsAsDescription, captureAstDefinitions, useAppliedDirectivesOnly, withValidation); } /** @@ -212,7 +228,7 @@ public Options useCommentsAsDescriptions(boolean useCommentsAsDescription) { * @return a new Options object */ public Options captureAstDefinitions(boolean captureAstDefinitions) { - return new Options(useCommentsAsDescription, captureAstDefinitions, useAppliedDirectivesOnly); + return new Options(useCommentsAsDescription, captureAstDefinitions, useAppliedDirectivesOnly, withValidation); } /** @@ -225,7 +241,23 @@ public Options captureAstDefinitions(boolean captureAstDefinitions) { * @return a new Options object */ public Options useAppliedDirectivesOnly(boolean useAppliedDirectivesOnly) { - return new Options(useCommentsAsDescription, captureAstDefinitions, useAppliedDirectivesOnly); + return new Options(useCommentsAsDescription, captureAstDefinitions, useAppliedDirectivesOnly, withValidation); + } + + /** + * Controls whether the generated schema is validated after construction. + *

    + * Note: This option is only supported by {@link FastSchemaGenerator}. + * The standard {@link SchemaGenerator} will throw {@link IllegalArgumentException} + * if validation is disabled. + * + * @param withValidation true to enable validation (default), false to skip validation + * + * @return a new Options object + */ + @ExperimentalApi + public Options withValidation(boolean withValidation) { + return new Options(useCommentsAsDescription, captureAstDefinitions, useAppliedDirectivesOnly, withValidation); } } -} \ No newline at end of file +} diff --git a/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy b/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy index 63ec1576b..30c600ead 100644 --- a/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy +++ b/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy @@ -2,8 +2,16 @@ package graphql.schema.idl import graphql.schema.GraphQLSchema import graphql.schema.idl.errors.SchemaProblem +import graphql.schema.validation.InvalidSchemaException import spock.lang.Specification +/** + * Tests for {@link FastSchemaGenerator}. + * + * Note: {@link GraphQLSchema.FastBuilder} is subject to extensive testing directly. + * The tests in this file are intended to test {@code FastSchemaGenerator} specifically + * and not the underlying builder. + */ class FastSchemaGeneratorTest extends Specification { def "can create simple schema using FastSchemaGenerator"() { @@ -138,7 +146,7 @@ class FastSchemaGeneratorTest extends Specification { // Regression tests to ensure FastSchemaGenerator behaves like SchemaGenerator - def "should throw SchemaProblem for missing type reference"() { + def "should throw SchemaProblem for missing type reference even with validation disabled"() { given: def sdl = ''' type Query { @@ -148,6 +156,7 @@ class FastSchemaGeneratorTest extends Specification { when: new FastSchemaGenerator().makeExecutableSchema( + SchemaGenerator.Options.defaultOptions().withValidation(false), new SchemaParser().parse(sdl), RuntimeWiring.MOCKED_WIRING ) @@ -156,7 +165,7 @@ class FastSchemaGeneratorTest extends Specification { thrown(SchemaProblem) } - def "should throw SchemaProblem for duplicate field definitions"() { + def "should throw SchemaProblem for duplicate field definitions even with validation disabled"() { given: def sdl = ''' type Query { @@ -167,6 +176,7 @@ class FastSchemaGeneratorTest extends Specification { when: new FastSchemaGenerator().makeExecutableSchema( + SchemaGenerator.Options.defaultOptions().withValidation(false), new SchemaParser().parse(sdl), RuntimeWiring.MOCKED_WIRING ) @@ -175,7 +185,7 @@ class FastSchemaGeneratorTest extends Specification { thrown(SchemaProblem) } - def "should throw SchemaProblem for invalid interface implementation"() { + def "should throw SchemaProblem for invalid interface implementation even with validation disabled"() { given: def sdl = ''' type Query { @@ -193,12 +203,14 @@ class FastSchemaGeneratorTest extends Specification { when: new FastSchemaGenerator().makeExecutableSchema( + SchemaGenerator.Options.defaultOptions().withValidation(false), new SchemaParser().parse(sdl), RuntimeWiring.MOCKED_WIRING ) then: // User claims to implement Node but doesn't have the required 'id' field + // This is caught by SchemaTypeChecker, not SchemaValidator thrown(SchemaProblem) } @@ -277,4 +289,44 @@ class FastSchemaGeneratorTest extends Specification { assert standardTypeNames.contains(introspectionType) : "Extra introspection type: $introspectionType" } } + + // Validation tests - test that 2-arg method validates and 4-arg with validation=false skips validation + + def "default makeExecutableSchema validates and throws InvalidSchemaException for non-null self-referencing input type"() { + given: + // Non-null self-reference in input type is impossible to satisfy + def sdl = ''' + type Query { test(input: BadInput): String } + input BadInput { self: BadInput! } + ''' + + when: + new FastSchemaGenerator().makeExecutableSchema( + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + thrown(InvalidSchemaException) + } + + def "3-arg makeExecutableSchema with withValidation=false allows non-null self-referencing input type"() { + given: + // Non-null self-reference in input type - passes without validation + def sdl = ''' + type Query { test(input: BadInput): String } + input BadInput { self: BadInput! } + ''' + + when: + def schema = new FastSchemaGenerator().makeExecutableSchema( + SchemaGenerator.Options.defaultOptions().withValidation(false), + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + notThrown(InvalidSchemaException) + schema != null + } } diff --git a/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy b/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy index 9eeed6c01..5c268ebd0 100644 --- a/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy +++ b/src/test/groovy/graphql/schema/idl/SchemaGeneratorTest.groovy @@ -2525,7 +2525,7 @@ class SchemaGeneratorTest extends Specification { type Query { f(arg : OneOfInputType) : String } - + input OneOfInputType @oneOf { a : String b : String @@ -2541,4 +2541,18 @@ class SchemaGeneratorTest extends Specification { inputObjectType.isOneOf() inputObjectType.hasAppliedDirective("oneOf") } + + def "should throw IllegalArgumentException when withValidation is false"() { + given: + def sdl = ''' + type Query { hello: String } + ''' + def options = SchemaGenerator.Options.defaultOptions().withValidation(false) + + when: + new SchemaGenerator().makeExecutableSchema(options, new SchemaParser().parse(sdl), RuntimeWiring.MOCKED_WIRING) + + then: + thrown(IllegalArgumentException) + } } From 23c59aed536138ca4e895c63a94450d58808994e Mon Sep 17 00:00:00 2001 From: Raymie Stata Date: Fri, 30 Jan 2026 12:01:26 -0800 Subject: [PATCH 25/25] Add type resolver validation to FastBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastBuilder now validates that all interfaces and unions have type resolvers, matching the behavior of the standard GraphQLSchema.Builder. This validation is always performed regardless of the withValidation() setting, which only controls GraphQL spec validation via SchemaValidator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../java/graphql/schema/GraphQLSchema.java | 22 +++-- .../graphql/schema/FastBuilderTest.groovy | 89 +++++++++++++++++-- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index 228616c34..8ebb1bd06 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -1413,19 +1413,31 @@ public FastBuilder withValidation(boolean enabled) { * * @return the built schema * @throws InvalidSchemaException if validation is enabled and the schema is invalid - * @throws AssertException if a type reference cannot be resolved + * @throws AssertException if a type reference cannot be resolved or if an interface/union + * type is missing a type resolver */ public GraphQLSchema build() { - // Step 1: Replace type references + // Validate type resolvers for all interfaces and unions + for (GraphQLNamedType type : typeMap.values()) { + if (type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType) { + String typeName = type.getName(); + if (!codeRegistryBuilder.hasTypeResolver(typeName)) { + String typeKind = type instanceof GraphQLInterfaceType ? "interface" : "union"; + assertShouldNeverHappen("You MUST provide a type resolver for the %s type '%s'", typeKind, typeName); + } + } + } + + // Replace type references shallowTypeRefCollector.replaceTypes(typeMap); - // Step 2: Add built-in directives if missing + // Add built-in directives if missing Directives.BUILT_IN_DIRECTIVES.forEach(this::addDirectiveIfMissing); - // Step 3: Create schema via private constructor + // Create schema via private constructor GraphQLSchema schema = new GraphQLSchema(this); - // Step 4: Optional validation + // Optional GraphQL spec validation if (validationEnabled) { Collection errors = new SchemaValidator().validateSchema(schema); if (!errors.isEmpty()) { diff --git a/src/test/groovy/graphql/schema/FastBuilderTest.groovy b/src/test/groovy/graphql/schema/FastBuilderTest.groovy index 2f0ea6915..795abc396 100644 --- a/src/test/groovy/graphql/schema/FastBuilderTest.groovy +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -1067,9 +1067,13 @@ class FastBuilderTest extends Specification { .type(postType)) .build() + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Node", { env -> null }) + when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( - GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + codeRegistry, queryType, null, null) .addType(nodeInterface) .addType(postType) .build() @@ -1120,9 +1124,13 @@ class FastBuilderTest extends Specification { .type(entityInterface)) .build() + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Entity", { env -> null }) + when: "building with FastBuilder" def schema = new GraphQLSchema.FastBuilder( - GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + codeRegistry, queryType, null, null) .addType(entityInterface) .addType(userType) .addType(productType) @@ -1686,6 +1694,42 @@ class FastBuilderTest extends Specification { schema.codeRegistry.getTypeResolver(resolvedUnion) != null } + def "union without type resolver throws error"() { + given: "a union without type resolver" + def catType = newObject() + .name("Cat") + .field(newFieldDefinition() + .name("meow") + .type(GraphQLString)) + .build() + + def petUnion = GraphQLUnionType.newUnionType() + .name("Pet") + .possibleType(catType) + // No type resolver! + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("pet") + .type(petUnion)) + .build() + + when: "building without type resolver" + new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .addType(catType) + .addType(petUnion) + .build() + + then: "error is thrown" + def e = thrown(AssertException) + e.message.contains("MUST provide a type resolver") + e.message.contains("Pet") + } + def "union with missing member type reference throws error"() { given: "a union with missing type reference" def petUnion = GraphQLUnionType.newUnionType() @@ -1780,7 +1824,7 @@ class FastBuilderTest extends Specification { resolvedApplied.getArgument("info").getType() == metaScalar } - def "withValidation(false) allows schema without type resolver"() { + def "interface without type resolver throws error"() { given: "an interface without type resolver" def nodeInterface = GraphQLInterfaceType.newInterface() .name("Node") @@ -1798,16 +1842,45 @@ class FastBuilderTest extends Specification { .type(nodeInterface)) .build() - when: "building with validation disabled" - def schema = new GraphQLSchema.FastBuilder( + when: "building without type resolver" + new GraphQLSchema.FastBuilder( + GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) + .addType(nodeInterface) + .build() + + then: "error is thrown" + def e = thrown(AssertException) + e.message.contains("MUST provide a type resolver") + e.message.contains("Node") + } + + def "withValidation(false) still requires type resolvers"() { + given: "an interface without type resolver" + def nodeInterface = GraphQLInterfaceType.newInterface() + .name("Node") + .field(newFieldDefinition() + .name("id") + .type(GraphQLString)) + .build() + + and: "a query type" + def queryType = newObject() + .name("Query") + .field(newFieldDefinition() + .name("node") + .type(nodeInterface)) + .build() + + when: "building with validation disabled but no type resolver" + new GraphQLSchema.FastBuilder( GraphQLCodeRegistry.newCodeRegistry(), queryType, null, null) .addType(nodeInterface) .withValidation(false) .build() - then: "schema builds without error" - schema != null - schema.getType("Node") instanceof GraphQLInterfaceType + then: "error is still thrown for missing type resolver" + def e = thrown(AssertException) + e.message.contains("MUST provide a type resolver") } def "withValidation(true) rejects schema with incomplete interface implementation"() {