diff --git a/build.gradle b/build.gradle index 7e6c48c07..37708f309 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..8d32847ac --- /dev/null +++ b/src/jmh/java/benchmark/BuildSchemaBenchmark.java @@ -0,0 +1,54 @@ +package benchmark; + +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( + 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 6d2099041..969f93c75 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,26 @@ 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( + SchemaGenerator.Options.defaultOptions().withValidation(false), + 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 +70,4 @@ public static void mainXXX(String[] args) { } } } -} \ No newline at end of file +} diff --git a/src/main/java/graphql/Assert.java b/src/main/java/graphql/Assert.java index 8a2925278..c4e79640b 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; @@ -224,8 +223,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, @@ -234,11 +231,37 @@ 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()) { + public static String assertValidName(@Nullable String name) { + if (name != null && isValidName(name)) { return name; } - return throwAssert(invalidNameErrorMessage, name); + return throwAssert(invalidNameErrorMessage, String.valueOf(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) { diff --git a/src/main/java/graphql/schema/GraphQLSchema.java b/src/main/java/graphql/schema/GraphQLSchema.java index d8e166cc0..8ebb1bd06 100644 --- a/src/main/java/graphql/schema/GraphQLSchema.java +++ b/src/main/java/graphql/schema/GraphQLSchema.java @@ -5,8 +5,11 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import graphql.Assert; +import graphql.AssertException; import graphql.Directives; +import graphql.Scalars; import graphql.DirectivesUtil; +import graphql.ExperimentalApi; import graphql.Internal; import graphql.PublicApi; import graphql.collect.ImmutableKit; @@ -19,10 +22,12 @@ 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; @@ -37,7 +42,7 @@ import static graphql.collect.ImmutableKit.nonNullCopyOf; import static graphql.schema.GraphqlTypeComparators.byNameAsc; import static graphql.schema.GraphqlTypeComparators.sortTypes; - +import static java.util.Collections.singletonList; /** * The schema represents the combined type system of the graphql engine. This is how the engine knows @@ -160,6 +165,64 @@ public GraphQLSchema(BuilderWithoutTypes builder) { this.codeRegistry = builder.codeRegistry; } + /** + * Private constructor for FastBuilder that copies data from the builder + * and converts mutable collections to immutable ones. + */ + @Internal + 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; + // 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()); + 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 = fastBuilder.codeRegistryBuilder.build(); + this.typeMap = finalTypeMap; + this.interfaceNameToObjectTypes = finalInterfaceMap; + this.interfaceNameToObjectTypeNames = finalInterfaceNameMap; + } + private static GraphQLDirective[] schemaDirectivesArray(GraphQLSchema existingSchema) { return existingSchema.schemaAppliedDirectivesHolder.getDirectives().toArray(new GraphQLDirective[0]); } @@ -272,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 * @@ -998,5 +1068,390 @@ 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); + } + } + } + + /** + * A high-performance schema builder that avoids full-schema traversals performed by + * {@link GraphQLSchema.Builder#build()}. This builder is significantly faster (5x+) and + * allocates significantly less memory than the standard Builder. It is intended for + * constructing large schemas (500+ types), especially deeply nested ones. + * + *

When to use FastBuilder

+ *
    + *
  • 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
  • + *
+ * + *

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 + 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 @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 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) { + addType(mutationType); + } + if (subscriptionType != null) { + addType(subscriptionType); + } + } + + /** + * 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 + */ + public FastBuilder addType(GraphQLNamedType type) { + + String name = type.getName(); + + // Enforce uniqueness by name + GraphQLNamedType existing = typeMap.get(name); + if (existing != null && existing != type) { + 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, type); + + // Shallow scan via ShallowTypeRefCollector (also tracks interface implementations) + shallowTypeRefCollector.handleTypeDef(type); + + // For interface types, wire type resolver if present + if (type instanceof GraphQLInterfaceType) { + GraphQLInterfaceType interfaceType = (GraphQLInterfaceType) type; + TypeResolver resolver = interfaceType.getTypeResolver(); + if (resolver != null) { + codeRegistryBuilder.typeResolverIfAbsent(interfaceType, resolver); + } + } + + // For union types, wire type resolver if present + if (type instanceof GraphQLUnionType) { + GraphQLUnionType unionType = (GraphQLUnionType) type; + TypeResolver resolver = unionType.getTypeResolver(); + if (resolver != null) { + codeRegistryBuilder.typeResolverIfAbsent(unionType, resolver); + } + } + + return this; + } + + /** + * 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 named types to add + * @return this builder for chaining + */ + public FastBuilder addTypes(Collection types) { + types.forEach(this::addType); + 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) { + 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; + } + + /** + * Adds multiple directive definitions to the schema. + * + * @param directives the directives to add + * @return this builder for chaining + */ + public FastBuilder additionalDirectives(Collection directives) { + 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) { + 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) { + 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) { + schemaAppliedDirectives.add(applied); + // Scan applied directive arguments for type references + shallowTypeRefCollector.scanAppliedDirectives(singletonList(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) { + for (GraphQLAppliedDirective applied : appliedList) { + withSchemaAppliedDirective(applied); + } + 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. + *

+ * 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 or if an interface/union + * type is missing a type resolver + */ + public GraphQLSchema build() { + // 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); + + // Add built-in directives if missing + Directives.BUILT_IN_DIRECTIVES.forEach(this::addDirectiveIfMissing); + + // Create schema via private constructor + GraphQLSchema schema = new GraphQLSchema(this); + + // Optional GraphQL spec validation + if (validationEnabled) { + Collection errors = new SchemaValidator().validateSchema(schema); + if (!errors.isEmpty()) { + throw new InvalidSchemaException(errors); + } + } + + return schema; + } + + private void addDirectiveIfMissing(GraphQLDirective directive) { + if (!directiveMap.containsKey(directive.getName())) { + directiveMap.put(directive.getName(), directive); + } + } } } diff --git a/src/main/java/graphql/schema/ShallowTypeRefCollector.java b/src/main/java/graphql/schema/ShallowTypeRefCollector.java new file mode 100644 index 000000000..d597b63b2 --- /dev/null +++ b/src/main/java/graphql/schema/ShallowTypeRefCollector.java @@ -0,0 +1,429 @@ +package graphql.schema; + +import com.google.common.collect.ImmutableList; +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; +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 +@NullMarked +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<>(); + + // 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. + * + * @param type the named type to scan + */ + public void handleTypeDef(GraphQLNamedType type) { + if (type instanceof GraphQLInputObjectType) { + 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()); + } + if (type instanceof GraphQLUnionType) { + handleUnionType((GraphQLUnionType) type); + } + } + + 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()); + } + // 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)); + } + } + + private boolean hasInterfaceTypeReferences(List interfaces) { + for (GraphQLNamedOutputType iface : interfaces) { + if (iface instanceof GraphQLTypeReference) { + return true; + } + } + 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. + */ + static class ObjectInterfaceReplaceTarget { + final GraphQLObjectType objectType; + + ObjectInterfaceReplaceTarget(GraphQLObjectType objectType) { + this.objectType = objectType; + } + } + + /** + * Wrapper class to track interface types that need interface replacement. + */ + static class InterfaceInterfaceReplaceTarget { + final GraphQLInterfaceType interfaceType; + + InterfaceInterfaceReplaceTarget(GraphQLInterfaceType interfaceType) { + this.interfaceType = interfaceType; + } + } + + 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())) { + 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); + } + } + } + } + + /** + * 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. + *

+ * 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 + */ + 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); + } 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); + } else if (target instanceof InterfaceInterfaceReplaceTarget) { + replaceInterfaceInterfaces((InterfaceInterfaceReplaceTarget) target, typeMap); + } else if (target instanceof UnionTypesReplaceTarget) { + replaceUnionTypes((UnionTypesReplaceTarget) target, typeMap); + } + } + } + + 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); + } + + private void replaceArgumentType(GraphQLArgument argument, Map typeMap) { + GraphQLInputType resolvedType = resolveInputType(argument.getType(), typeMap); + argument.replaceType(resolvedType); + } + + private void replaceFieldType(GraphQLFieldDefinition field, 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); + } + + private void replaceInterfaceInterfaces(InterfaceInterfaceReplaceTarget 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); + } + + 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. + */ + 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. + */ + 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; + } + + /** + * Returns an immutable map of interface names to sorted lists of implementing object type names. + * The object type names are maintained in sorted order as they're added. + * + * @return immutable map from interface name to list of object type names + */ + public ImmutableMap> 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/idl/FastSchemaGenerator.java b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java new file mode 100644 index 000000000..bc74c1332 --- /dev/null +++ b/src/main/java/graphql/schema/idl/FastSchemaGenerator.java @@ -0,0 +1,168 @@ +package graphql.schema.idl; + +import graphql.ExperimentalApi; +import graphql.GraphQLError; +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; +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; + +import static graphql.schema.idl.SchemaGeneratorHelper.buildDescription; + +/** + * 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 + */ +@ExperimentalApi +@NullMarked +public class FastSchemaGenerator { + + private final SchemaTypeChecker typeChecker = new SchemaTypeChecker(); + private final SchemaGeneratorHelper schemaGeneratorHelper = new SchemaGeneratorHelper(); + + /** + * Creates an executable schema from a TypeDefinitionRegistry using FastBuilder. + * + * @param typeRegistry the type definition registry + * @param wiring the runtime wiring + * @return a validated, 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(); + + // 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); + } + + 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 (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"); + 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.addTypes(allBuiltTypes); + fastBuilder.addTypes(additionalTypes); + + // Add all directive definitions + fastBuilder.additionalDirectives(additionalDirectives); + + // Add schema description and definition if present + typeRegistry.schemaDefinition().ifPresent(schemaDefinition -> { + String description = buildDescription(buildCtx, schemaDefinition, schemaDefinition.getDescription()); + fastBuilder.description(description); + fastBuilder.definition(schemaDefinition); + }); + + // Configure validation + fastBuilder.withValidation(options.isWithValidation()); + + 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 @Nullable GraphQLObjectType findOperationType(Set types, String typeName) { + for (GraphQLNamedType 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/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/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/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/FastBuilderComparisonComplexTest.groovy b/src/test/groovy/graphql/schema/FastBuilderComparisonComplexTest.groovy new file mode 100644 index 000000000..808fd7d02 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonComplexTest.groovy @@ -0,0 +1,847 @@ +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 { + + 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) + } + + 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..afad61b65 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonMigratedTest.groovy @@ -0,0 +1,395 @@ +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 { + + 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..bcd45c431 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonTest.groovy @@ -0,0 +1,186 @@ +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 { + + /** + * 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.addType(type) + } + } + + additionalDirectives.each { directive -> + if (directive != null) { + builder.additionalDirective(directive) + } + } + + return builder.build() + } + + /** + * 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) + * - 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) + 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}" + + // 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) + 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}" + } + } + + 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..4d733b7d8 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderComparisonTypeRefTest.groovy @@ -0,0 +1,1025 @@ +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 { + + 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 + } + + 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 + } + + 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] + } + + 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 + } + + 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 + } + + 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 + } + + 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 + } + + 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 new file mode 100644 index 000000000..795abc396 --- /dev/null +++ b/src/test/groovy/graphql/schema/FastBuilderTest.groovy @@ -0,0 +1,2111 @@ +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.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 + +class FastBuilderTest extends Specification { + + 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) + .addType(scalar1) + .addType(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) + .addType(scalar) + .addType(scalar) + .build() + + then: "no error and scalar is in schema" + schema.getType("MyScalar") != 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 "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) + .addTypes([scalar1, scalar2]) + .build() + + then: "both types are in schema" + schema.getType("Scalar1") != null + schema.getType("Scalar2") != null + } + + 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) + .addType(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) + .addType(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) + .addType(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 + } + + 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) + .addType(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 "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) + .addType(levelEnum) + .additionalDirective(directive) + .build() + + then: "directive argument type is resolved to enum" + def resolvedDirective = schema.getDirective("log") + resolvedDirective.getArgument("level").getType() == levelEnum + } + + 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) + .addType(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) + .addType(customScalar) + .addType(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) + .addType(addressInput) + .addType(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) + .addType(statusEnum) + .addType(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) + .addType(tagScalar) + .addType(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) + .addType(configInput) + .additionalDirective(directive) + .build() + + then: "directive argument type is resolved to input type" + def resolvedDirective = schema.getDirective("config") + resolvedDirective.getArgument("settings").getType() == configInput + } + + 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) + .addType(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) + .addType(customScalar) + .addType(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) + .addType(customScalar) + .addType(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 + } + + 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() + .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) + .addType(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) + .addType(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) + .addType(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() + + 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) + .addType(nodeInterface) + .addType(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() + + and: "code registry with type resolver" + def codeRegistry = GraphQLCodeRegistry.newCodeRegistry() + .typeResolver("Entity", { env -> null }) + + when: "building with FastBuilder" + def schema = new GraphQLSchema.FastBuilder( + codeRegistry, queryType, null, null) + .addType(entityInterface) + .addType(userType) + .addType(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) + .addType(filterInput) + .addType(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) + .addType(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) + .addType(objectType) + .build() + + then: "error for missing interface" + thrown(AssertException) + } + + 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) + .addType(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) + .addType(nodeInterface) + .addType(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) + .addType(nodeInterface) + .addType(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) + .addType(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) + .addType(searchableInterface) + .addType(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) + .addType(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) + .addType(metaScalar) + .addType(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 + } + + 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) + .addType(catType) + .addType(dogType) + .addType(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) + .addType(catType) + .addType(dogType) + .addType(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) + .addType(catType) + .addType(petUnion) + .build() + + then: "type resolver is wired" + def resolvedUnion = schema.getType("Pet") as GraphQLUnionType + 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() + .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) + .addType(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) + .addType(metaScalar) + .addType(catType) + .addType(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 + } + + def "interface without type resolver throws error"() { + 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 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: "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"() { + 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) + .addType(nodeInterface) + .addType(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) + .addType(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) + .addType(innerType) + .addType(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) + .addType(nodeInterface) + .addType(filterInput) + .addType(userType) + .addType(postType) + .addType(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 + } +} 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..30c600ead --- /dev/null +++ b/src/test/groovy/graphql/schema/idl/FastSchemaGeneratorTest.groovy @@ -0,0 +1,332 @@ +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"() { + 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 + } + + // Regression tests to ensure FastSchemaGenerator behaves like SchemaGenerator + + def "should throw SchemaProblem for missing type reference even with validation disabled"() { + given: + def sdl = ''' + type Query { + user: UnknownType + } + ''' + + when: + new FastSchemaGenerator().makeExecutableSchema( + SchemaGenerator.Options.defaultOptions().withValidation(false), + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + thrown(SchemaProblem) + } + + def "should throw SchemaProblem for duplicate field definitions even with validation disabled"() { + given: + def sdl = ''' + type Query { + hello: String + hello: Int + } + ''' + + when: + new FastSchemaGenerator().makeExecutableSchema( + SchemaGenerator.Options.defaultOptions().withValidation(false), + new SchemaParser().parse(sdl), + RuntimeWiring.MOCKED_WIRING + ) + + then: + thrown(SchemaProblem) + } + + def "should throw SchemaProblem for invalid interface implementation even with validation disabled"() { + given: + def sdl = ''' + type Query { + node: Node + } + + interface Node { + id: ID! + } + + type User implements Node { + name: String + } + ''' + + 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) + } + + 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" + } + } + + // 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) + } }