From 54aae49ed0c42ced4f53aa302503ae0ed778a1bb Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 10 Oct 2026 17:16:17 -0400 Subject: [PATCH 1/3] Flatten the discovery loop in SchemaClassScanner The nested do/while loops ran each step only once the steps before it had nothing left, and started over after anything new was found. One loop with a step per block says the same thing top to bottom, and makes room for more steps without nesting deeper. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- .../kickstart/tools/SchemaClassScanner.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index b48195fe..78557e3c 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -75,16 +75,19 @@ internal class SchemaClassScanner( scanQueue() - // Loop over all objects scanning each one only once for more objects to discover. - do { - do { - // Require all implementors of discovered interfaces to be discovered or provided. - handleDictionaryTypes(getAllObjectTypesImplementingDiscoveredInterfaces()) { "Object type '${it.name}' implements a known interface, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary." } - } while (scanQueue()) + // Loop over all objects scanning each one only once for more objects to discover. Each step only runs once the steps + // above it have nothing left, and anything new starts over from the top. + while (true) { + // Require all implementors of discovered interfaces to be discovered or provided. + handleDictionaryTypes(getAllObjectTypesImplementingDiscoveredInterfaces()) { "Object type '${it.name}' implements a known interface, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary." } + if (scanQueue()) continue // Require all members of discovered unions to be discovered. handleDictionaryTypes(getAllObjectTypeMembersOfDiscoveredUnions()) { "Object type '${it.name}' is a member of a known union, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary." } - } while (scanQueue()) + if (scanQueue()) continue + + break + } handleDirectives() From 29c87af87a4cab227e5831414e964a6547d625f1 Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 10 Oct 2026 17:16:47 -0400 Subject: [PATCH 2/3] Discover unions and interfaces used only by interface fields An implementation may narrow an interface field to one member of the field's union (or one implementor of its interface), which is valid GraphQL. The scanner only discovers types through object fields and "implements" clauses, so a union referenced only from an interface field was never added to the definitions. The schema build then failed in createInterfaceObject with "Expected type 'TestUnion' to be a GraphQLOutputType, but it wasn't!", and the union was logged as unused. Once nothing else is left to scan, the discovery loop now also walks the field types of every discovered interface and adds any union or interface that hasn't been found yet. The existing loops then require classes for the union's members and the interface's implementors, as they do for types reached through object fields. Running this step last keeps the discovery order unchanged for every type reachable some other way, so a union whose members are only found by scanning dictionary types is still found after those types are scanned. Unions only used by interface fields no longer need a dummy field or includeUnusedTypes(true) to be built. The step is skipped when includeUnusedTypes is on. The unused types loop already picks these unions and interfaces up one at a time, scanning in between, so a member that is only reachable through another member's class is still found there. Checking all members at once would reject schemas that build today with the includeUnusedTypes workaround. Fixes #597, the same problem as #382. Co-Authored-By: Claude Opus 5.5 --- .../kickstart/tools/SchemaClassScanner.kt | 25 + .../kickstart/tools/SchemaClassScannerTest.kt | 501 ++++++++++++++++++ 2 files changed, 526 insertions(+) diff --git a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt index 78557e3c..8844ba6b 100644 --- a/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt @@ -86,6 +86,11 @@ internal class SchemaClassScanner( handleDictionaryTypes(getAllObjectTypeMembersOfDiscoveredUnions()) { "Object type '${it.name}' is a member of a known union, but no class could be found for that type name. Please pass a class for type '${it.name}' in the parser's dictionary." } if (scanQueue()) continue + // Look for unions and interfaces used only by interface fields last, so types that can be reached some other way + // are still discovered in the same order as before. With includeUnusedTypes the unused types loop below already + // picks these up one at a time, scanning in between, so leave that path unchanged. + if (!options.includeUnusedTypes && handleInterfaceFieldTypes()) continue + break } @@ -273,6 +278,26 @@ internal class SchemaClassScanner( }.flatten().distinct() } + /** + * Implementations can narrow an interface field to a member of its union or an implementor of its interface, so such a + * union or interface may not be reachable from any object field. Returns whether any new types were found. + */ + private fun handleInterfaceFieldTypes(): Boolean { + val newTypes = dictionary.keys.filterIsInstance().flatMap { iface -> + iface.fieldDefinitions.mapNotNull { field -> + val type = definitionsByName[(field.type.unwrap() as TypeName).name] + if ((type is UnionTypeDefinition || type is InterfaceTypeDefinition) && !dictionary.containsKey(type)) { + type to FieldTypeReference("${iface.name}.${field.name}") + } else { + null + } + } + } + + newTypes.forEach { (type, reference) -> handleFoundType(type, null, reference) } + return newTypes.isNotEmpty() + } + private fun handleDictionaryTypes(types: List>, failureMessage: (TypeDefinition<*>) -> String) { types.forEach { type -> val dictionaryContainsType = dictionary.filter { it.key.name == type.name }.isNotEmpty() diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt index 24f0e662..35cfa194 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt @@ -1,10 +1,12 @@ package graphql.kickstart.tools +import graphql.GraphQL import graphql.GraphQLContext import graphql.execution.CoercedVariables import graphql.language.Value import graphql.schema.* import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertThrows import org.junit.Test import java.util.* import java.util.concurrent.CompletableFuture @@ -736,4 +738,503 @@ class SchemaClassScannerTest { return null } } + + private val interfaceOnlyUnionSchema = """ + type Query { + query: TestInterface + } + + interface TestInterface { + testUnion: TestUnion + } + + type TestInterfaceImpl implements TestInterface { + testUnion: TestUnion1 + c: String + } + + type TestUnion1 { + a: String + } + + type TestUnion2 { + b: String + } + + union TestUnion = TestUnion1 | TestUnion2 + """ + + @Test + fun `scanner handles unions used only by interface fields`() { + val schema = SchemaParser.newParser() + .schemaString(interfaceOnlyUnionSchema) + .resolvers(InterfaceOnlyUnion()) + .dictionary(InterfaceOnlyUnion.TestInterfaceImpl::class, InterfaceOnlyUnion.TestUnion2::class) + .build() + .makeExecutableSchema() + + val testUnion = schema.getType("TestUnion") as GraphQLUnionType + assertEquals(testUnion.types.map { it.name }.toSet(), setOf("TestUnion1", "TestUnion2")) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + query { + testUnion { + ... on TestUnion1 { a } + } + } + } + """ + } + + assertEquals(data["query"], mapOf("testUnion" to mapOf("a" to "a"))) + } + + @Test + fun `scanner requires classes for all members of unions used only by interface fields`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString(interfaceOnlyUnionSchema) + .resolvers(InterfaceOnlyUnion()) + .dictionary(InterfaceOnlyUnion.TestInterfaceImpl::class) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Object type 'TestUnion2' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'TestUnion2' in the parser's dictionary.") + } + + class InterfaceOnlyUnion : GraphQLQueryResolver { + fun query(): TestInterface = TestInterfaceImpl(TestUnion1("a"), "c") + + interface TestInterface + + class TestInterfaceImpl(val testUnion: TestUnion1?, val c: String?) : TestInterface + + class TestUnion1(val a: String?) + + class TestUnion2(val b: String?) + } + + @Test + fun `scanner handles single member unions used only by interface fields`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + search: NodeSearchResponse + } + + enum NodeSortField { TITLE } + enum SortDirection { ASC DESC } + + union SortResponse = NodeSortResponse + union Result = Node + + interface SearchResponse { + results: [Result!]! + order: SortResponse + } + + type Node { + id: ID! + title: String + } + + type NodeSortResponse { + field: NodeSortField + direction: SortDirection + } + + type NodeSearchResponse implements SearchResponse { + results: [Node!]! + order: NodeSortResponse + } + """) + .resolvers(SingleMemberUnions()) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Result") as GraphQLUnionType).types.map { it.name }, listOf("Node")) + assertEquals((schema.getType("SortResponse") as GraphQLUnionType).types.map { it.name }, listOf("NodeSortResponse")) + } + + class SingleMemberUnions : GraphQLQueryResolver { + fun search(): NodeSearchResponse? = null + + enum class NodeSortField { TITLE } + + enum class SortDirection { ASC, DESC } + + class Node(val id: String, val title: String?) + + class NodeSortResponse(val field: NodeSortField?, val direction: SortDirection?) + + class NodeSearchResponse(val results: List, val order: NodeSortResponse?) + } + + @Test + fun `scanner handles unions used only by fields of interfaces implemented by other interfaces`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + named: NamedNode + } + + interface Node { + content: Content + } + + interface NamedNode implements Node { + content: Text + name: String + } + + type Document implements NamedNode & Node { + content: Text + name: String + } + + type Text { + text: String + } + + type Image { + url: String + } + + union Content = Text | Image + """) + .resolvers(NestedInterfaceUnion()) + .dictionary(NestedInterfaceUnion.Document::class, NestedInterfaceUnion.Image::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Content") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Text", "Image")) + } + + class NestedInterfaceUnion : GraphQLQueryResolver { + fun named(): NamedNode? = null + + interface NamedNode + + class Document(val content: Text?, val name: String?) : NamedNode + + class Text(val text: String?) + + class Image(val url: String?) + } + + @Test + fun `scanner handles unions used only by fields of interfaces discovered later in the scan`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + container: Container + } + + interface Container { + entry: Entry + } + + interface Entry { + content: Content + } + + type Box implements Container { + entry: Book + } + + type Book implements Entry { + content: Text + } + + type Text { + text: String + } + + type Image { + url: String + } + + union Content = Text | Image + """) + .resolvers(ChainedInterfaceUnion()) + .dictionary(ChainedInterfaceUnion.Box::class, ChainedInterfaceUnion.Image::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Content") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Text", "Image")) + } + + class ChainedInterfaceUnion : GraphQLQueryResolver { + fun container(): Container? = null + + interface Container + + class Box(val entry: Book?) : Container + + class Book(val content: Text?) + + class Text(val text: String?) + + class Image(val url: String?) + } + + @Test + fun `scanner handles interfaces used only by interface fields`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + container: Container + } + + interface Container { + entry: Entry + } + + interface Entry { + id: ID! + } + + interface Draft implements Entry { + id: ID! + } + + type Box implements Container { + entry: Draft + } + """) + .resolvers(InterfaceOnlyInterface()) + .dictionary(InterfaceOnlyInterface.Box::class) + .build() + .makeExecutableSchema() + + assert(schema.getType("Entry") is GraphQLInterfaceType) + } + + class InterfaceOnlyInterface : GraphQLQueryResolver { + fun container(): Container? = null + + interface Container + + interface Draft + + class Box(val entry: Draft?) : Container + } + + @Test + fun `scanner finds members of unions used by interface fields through dictionary types first`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + item: Item + other: OtherUnion + } + + interface Item { + content: Content + } + + type Book implements Item { + content: Text + } + + type Text { + text: String + } + + type Image { + url: String + } + + union Content = Text | Image + + union OtherUnion = Holder + + type Holder { + image: Image + content: Content + } + """) + .resolvers(DictionaryReachableUnion()) + .dictionary(DictionaryReachableUnion.Book::class, DictionaryReachableUnion.Holder::class) + .build() + .makeExecutableSchema() + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + item { + content { + ... on Text { text } + } + } + other { + ... on Holder { + image { url } + content { + ... on Image { url } + } + } + } + } + """ + } + + assertEquals(data["item"], mapOf("content" to mapOf("text" to "x"))) + assertEquals(data["other"], mapOf("image" to mapOf("url" to "u"), "content" to mapOf("url" to "v"))) + } + + class DictionaryReachableUnion : GraphQLQueryResolver { + fun item(): Item = Book(Text("x")) + fun other(): OtherUnion = Holder(Image("u"), Image("v")) + + interface Item + + interface Content + + interface OtherUnion + + class Book(val content: Text?) : Item + + class Text(val text: String?) : Content + + class Image(val url: String?) : Content + + class Holder(val image: Image?, val content: Content?) : OtherUnion + } + + @Test + fun `scanner finds implementors of interfaces used by interface fields through dictionary types first`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + wrapper: Wrapper + owner: Owner + } + + union Wrapper = Shelf + + type Shelf { + item: Item + book: Book + magazine: Magazine + } + + interface Item { + title: String + } + + type Book implements Item { + title: String + } + + type Magazine implements Item { + title: String + } + + interface Owner { + item: Item + } + """) + .resolvers(DictionaryReachableInterface()) + .dictionary(DictionaryReachableInterface.Shelf::class) + .build() + .makeExecutableSchema() + + assertEquals(schema.getImplementations(schema.getType("Item") as GraphQLInterfaceType)!!.map { it.name }.toSet(), setOf("Book", "Magazine")) + } + + class DictionaryReachableInterface : GraphQLQueryResolver { + fun wrapper(): Wrapper? = null + fun owner(): Owner? = null + + interface Wrapper + + interface Owner + + interface Item + + class Shelf(val item: Item?, val book: Book?, val magazine: Magazine?) : Wrapper + + class Book(val title: String?) : Item + + class Magazine(val title: String?) : Item + } + + @Test + fun `scanner finds members of unions used by interface fields through unused types when option is true`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + item: Item + } + + interface Item { + content: Content + } + + type Book implements Item { + content: Text + } + + type Text { + text: String + } + + type Image { + url: String + caption: Caption + } + + type Caption { + value: String + } + + union Content = Text | Image | Caption + """) + .resolvers(UnusedTypesReachableUnion()) + .dictionary(UnusedTypesReachableUnion.Book::class, UnusedTypesReachableUnion.Image::class) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .build() + .makeExecutableSchema() + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + item { + content { + ... on Text { text } + } + } + } + """ + } + + assertEquals(data["item"], mapOf("content" to mapOf("text" to "t"))) + } + + class UnusedTypesReachableUnion : GraphQLQueryResolver { + fun item(): Item = Book(Text("t")) + + interface Item + + interface Content + + class Book(val content: Text?) : Item + + class Text(val text: String?) : Content + + class Image(val url: String?, val caption: Caption?) : Content + + class Caption(val value: String?) : Content + } } From 06b0d68b07b290110f4b6ec98ec213761944ecca Mon Sep 17 00:00:00 2001 From: Oryan Date: Sat, 10 Oct 2026 17:25:24 -0400 Subject: [PATCH 3/3] Move interface field type tests to their own class The tests for unions and interfaces used only by interface fields made SchemaClassScannerTest the largest test file, so they now live in SchemaClassScannerInterfaceFieldTest, like SchemaClassScannerDirectiveTest. The test for a union on the field of a parent interface is dropped. The scanner finds that interface through the object's own implements clause, the same path as the test for interfaces discovered later in the scan. The ordering tests no longer run a query, since building the schema is what they check. A new test covers a union that is only found through a member of another such union, which needs a second pass of the step. Co-Authored-By: Claude Opus 5.5 --- .../SchemaClassScannerInterfaceFieldTest.kt | 489 +++++++++++++++++ .../kickstart/tools/SchemaClassScannerTest.kt | 501 ------------------ 2 files changed, 489 insertions(+), 501 deletions(-) create mode 100644 src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerInterfaceFieldTest.kt diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerInterfaceFieldTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerInterfaceFieldTest.kt new file mode 100644 index 00000000..e3b8d5d7 --- /dev/null +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerInterfaceFieldTest.kt @@ -0,0 +1,489 @@ +package graphql.kickstart.tools + +import graphql.GraphQL +import graphql.schema.GraphQLInterfaceType +import graphql.schema.GraphQLUnionType +import org.junit.Assert.assertThrows +import org.junit.Test + +class SchemaClassScannerInterfaceFieldTest { + + private val interfaceOnlyUnionSchema = """ + type Query { + query: TestInterface + } + + interface TestInterface { + testUnion: TestUnion + } + + type TestInterfaceImpl implements TestInterface { + testUnion: TestUnion1 + c: String + } + + type TestUnion1 { + a: String + } + + type TestUnion2 { + b: String + } + + union TestUnion = TestUnion1 | TestUnion2 + """ + + @Test + fun `scanner handles unions used only by interface fields`() { + val schema = SchemaParser.newParser() + .schemaString(interfaceOnlyUnionSchema) + .resolvers(InterfaceOnlyUnion()) + .dictionary(InterfaceOnlyUnion.TestInterfaceImpl::class, InterfaceOnlyUnion.TestUnion2::class) + .build() + .makeExecutableSchema() + + val testUnion = schema.getType("TestUnion") as GraphQLUnionType + assertEquals(testUnion.types.map { it.name }.toSet(), setOf("TestUnion1", "TestUnion2")) + + val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { + """ + query { + query { + testUnion { + ... on TestUnion1 { a } + } + } + } + """ + } + + assertEquals(data["query"], mapOf("testUnion" to mapOf("a" to "a"))) + } + + @Test + fun `scanner requires classes for all members of unions used only by interface fields`() { + val error = assertThrows(SchemaClassScannerError::class.java) { + SchemaParser.newParser() + .schemaString(interfaceOnlyUnionSchema) + .resolvers(InterfaceOnlyUnion()) + .dictionary(InterfaceOnlyUnion.TestInterfaceImpl::class) + .build() + .makeExecutableSchema() + } + + assertEquals(error.message, "Object type 'TestUnion2' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'TestUnion2' in the parser's dictionary.") + } + + class InterfaceOnlyUnion : GraphQLQueryResolver { + fun query(): TestInterface = TestInterfaceImpl(TestUnion1("a"), "c") + + interface TestInterface + + class TestInterfaceImpl(val testUnion: TestUnion1?, val c: String?) : TestInterface + + class TestUnion1(val a: String?) + + class TestUnion2(val b: String?) + } + + @Test + fun `scanner handles single member unions used only by interface fields`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + search: NodeSearchResponse + } + + enum NodeSortField { TITLE } + enum SortDirection { ASC DESC } + + union SortResponse = NodeSortResponse + union Result = Node + + interface SearchResponse { + results: [Result!]! + order: SortResponse + } + + type Node { + id: ID! + title: String + } + + type NodeSortResponse { + field: NodeSortField + direction: SortDirection + } + + type NodeSearchResponse implements SearchResponse { + results: [Node!]! + order: NodeSortResponse + } + """) + .resolvers(SingleMemberUnions()) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Result") as GraphQLUnionType).types.map { it.name }, listOf("Node")) + assertEquals((schema.getType("SortResponse") as GraphQLUnionType).types.map { it.name }, listOf("NodeSortResponse")) + } + + class SingleMemberUnions : GraphQLQueryResolver { + fun search(): NodeSearchResponse? = null + + enum class NodeSortField { TITLE } + + enum class SortDirection { ASC, DESC } + + class Node(val id: String, val title: String?) + + class NodeSortResponse(val field: NodeSortField?, val direction: SortDirection?) + + class NodeSearchResponse(val results: List, val order: NodeSortResponse?) + } + + @Test + fun `scanner handles unions used only by fields of interfaces discovered later in the scan`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + container: Container + } + + interface Container { + entry: Entry + } + + interface Entry { + content: Content + } + + type Box implements Container { + entry: Book + } + + type Book implements Entry { + content: Text + } + + type Text { + text: String + } + + type Image { + url: String + } + + union Content = Text | Image + """) + .resolvers(ChainedInterfaceUnion()) + .dictionary(ChainedInterfaceUnion.Box::class, ChainedInterfaceUnion.Image::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Content") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Text", "Image")) + } + + class ChainedInterfaceUnion : GraphQLQueryResolver { + fun container(): Container? = null + + interface Container + + class Box(val entry: Book?) : Container + + class Book(val content: Text?) + + class Text(val text: String?) + + class Image(val url: String?) + } + + @Test + fun `scanner handles unions used only by interface fields that are found through another such union`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + item: Item + } + + interface Item { + content: Content + } + + type Book implements Item { + content: Text + } + + type Text { + text: String + } + + type Image implements Captioned { + url: String + caption: PlainCaption + } + + union Content = Text | Image + + interface Captioned { + caption: Caption + } + + type PlainCaption { + value: String + } + + type RichCaption { + html: String + } + + union Caption = PlainCaption | RichCaption + """) + .resolvers(NestedInterfaceOnlyUnion()) + .dictionary(NestedInterfaceOnlyUnion.Book::class, NestedInterfaceOnlyUnion.Image::class, NestedInterfaceOnlyUnion.RichCaption::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Caption") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("PlainCaption", "RichCaption")) + } + + class NestedInterfaceOnlyUnion : GraphQLQueryResolver { + fun item(): Item? = null + + interface Item + + class Book(val content: Text?) : Item + + class Text(val text: String?) + + class Image(val url: String?, val caption: PlainCaption?) + + class PlainCaption(val value: String?) + + class RichCaption(val html: String?) + } + + @Test + fun `scanner handles interfaces used only by interface fields`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + container: Container + } + + interface Container { + entry: Entry + } + + interface Entry { + id: ID! + } + + interface Draft implements Entry { + id: ID! + } + + type Box implements Container { + entry: Draft + } + """) + .resolvers(InterfaceOnlyInterface()) + .dictionary(InterfaceOnlyInterface.Box::class) + .build() + .makeExecutableSchema() + + assert(schema.getType("Entry") is GraphQLInterfaceType) + } + + class InterfaceOnlyInterface : GraphQLQueryResolver { + fun container(): Container? = null + + interface Container + + interface Draft + + class Box(val entry: Draft?) : Container + } + + @Test + fun `scanner finds members of unions used by interface fields through dictionary types first`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + item: Item + other: OtherUnion + } + + interface Item { + content: Content + } + + type Book implements Item { + content: Text + } + + type Text { + text: String + } + + type Image { + url: String + } + + union Content = Text | Image + + union OtherUnion = Holder + + type Holder { + image: Image + content: Content + } + """) + .resolvers(DictionaryReachableUnion()) + .dictionary(DictionaryReachableUnion.Book::class, DictionaryReachableUnion.Holder::class) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Content") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Text", "Image")) + } + + class DictionaryReachableUnion : GraphQLQueryResolver { + fun item(): Item? = null + fun other(): OtherUnion? = null + + interface Item + + interface Content + + interface OtherUnion + + class Book(val content: Text?) : Item + + class Text(val text: String?) : Content + + class Image(val url: String?) : Content + + class Holder(val image: Image?, val content: Content?) : OtherUnion + } + + @Test + fun `scanner finds implementors of interfaces used by interface fields through dictionary types first`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + wrapper: Wrapper + owner: Owner + } + + union Wrapper = Shelf + + type Shelf { + item: Item + book: Book + magazine: Magazine + } + + interface Item { + title: String + } + + type Book implements Item { + title: String + } + + type Magazine implements Item { + title: String + } + + interface Owner { + item: Item + } + """) + .resolvers(DictionaryReachableInterface()) + .dictionary(DictionaryReachableInterface.Shelf::class) + .build() + .makeExecutableSchema() + + assertEquals(schema.getImplementations(schema.getType("Item") as GraphQLInterfaceType)!!.map { it.name }.toSet(), setOf("Book", "Magazine")) + } + + class DictionaryReachableInterface : GraphQLQueryResolver { + fun wrapper(): Wrapper? = null + fun owner(): Owner? = null + + interface Wrapper + + interface Owner + + interface Item + + class Shelf(val item: Item?, val book: Book?, val magazine: Magazine?) : Wrapper + + class Book(val title: String?) : Item + + class Magazine(val title: String?) : Item + } + + @Test + fun `scanner finds members of unions used by interface fields through unused types when option is true`() { + val schema = SchemaParser.newParser() + .schemaString( + """ + type Query { + item: Item + } + + interface Item { + content: Content + } + + type Book implements Item { + content: Text + } + + type Text { + text: String + } + + type Image { + url: String + caption: Caption + } + + type Caption { + value: String + } + + union Content = Text | Image | Caption + """) + .resolvers(UnusedTypesReachableUnion()) + .dictionary(UnusedTypesReachableUnion.Book::class, UnusedTypesReachableUnion.Image::class) + .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) + .build() + .makeExecutableSchema() + + assertEquals((schema.getType("Content") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Text", "Image", "Caption")) + } + + class UnusedTypesReachableUnion : GraphQLQueryResolver { + fun item(): Item? = null + + interface Item + + interface Content + + class Book(val content: Text?) : Item + + class Text(val text: String?) : Content + + class Image(val url: String?, val caption: Caption?) : Content + + class Caption(val value: String?) : Content + } +} diff --git a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt index 35cfa194..24f0e662 100644 --- a/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt +++ b/src/test/kotlin/graphql/kickstart/tools/SchemaClassScannerTest.kt @@ -1,12 +1,10 @@ package graphql.kickstart.tools -import graphql.GraphQL import graphql.GraphQLContext import graphql.execution.CoercedVariables import graphql.language.Value import graphql.schema.* import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.junit.Assert.assertThrows import org.junit.Test import java.util.* import java.util.concurrent.CompletableFuture @@ -738,503 +736,4 @@ class SchemaClassScannerTest { return null } } - - private val interfaceOnlyUnionSchema = """ - type Query { - query: TestInterface - } - - interface TestInterface { - testUnion: TestUnion - } - - type TestInterfaceImpl implements TestInterface { - testUnion: TestUnion1 - c: String - } - - type TestUnion1 { - a: String - } - - type TestUnion2 { - b: String - } - - union TestUnion = TestUnion1 | TestUnion2 - """ - - @Test - fun `scanner handles unions used only by interface fields`() { - val schema = SchemaParser.newParser() - .schemaString(interfaceOnlyUnionSchema) - .resolvers(InterfaceOnlyUnion()) - .dictionary(InterfaceOnlyUnion.TestInterfaceImpl::class, InterfaceOnlyUnion.TestUnion2::class) - .build() - .makeExecutableSchema() - - val testUnion = schema.getType("TestUnion") as GraphQLUnionType - assertEquals(testUnion.types.map { it.name }.toSet(), setOf("TestUnion1", "TestUnion2")) - - val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { - """ - query { - query { - testUnion { - ... on TestUnion1 { a } - } - } - } - """ - } - - assertEquals(data["query"], mapOf("testUnion" to mapOf("a" to "a"))) - } - - @Test - fun `scanner requires classes for all members of unions used only by interface fields`() { - val error = assertThrows(SchemaClassScannerError::class.java) { - SchemaParser.newParser() - .schemaString(interfaceOnlyUnionSchema) - .resolvers(InterfaceOnlyUnion()) - .dictionary(InterfaceOnlyUnion.TestInterfaceImpl::class) - .build() - .makeExecutableSchema() - } - - assertEquals(error.message, "Object type 'TestUnion2' is a member of a known union, but no class could be found for that type name. Please pass a class for type 'TestUnion2' in the parser's dictionary.") - } - - class InterfaceOnlyUnion : GraphQLQueryResolver { - fun query(): TestInterface = TestInterfaceImpl(TestUnion1("a"), "c") - - interface TestInterface - - class TestInterfaceImpl(val testUnion: TestUnion1?, val c: String?) : TestInterface - - class TestUnion1(val a: String?) - - class TestUnion2(val b: String?) - } - - @Test - fun `scanner handles single member unions used only by interface fields`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - search: NodeSearchResponse - } - - enum NodeSortField { TITLE } - enum SortDirection { ASC DESC } - - union SortResponse = NodeSortResponse - union Result = Node - - interface SearchResponse { - results: [Result!]! - order: SortResponse - } - - type Node { - id: ID! - title: String - } - - type NodeSortResponse { - field: NodeSortField - direction: SortDirection - } - - type NodeSearchResponse implements SearchResponse { - results: [Node!]! - order: NodeSortResponse - } - """) - .resolvers(SingleMemberUnions()) - .build() - .makeExecutableSchema() - - assertEquals((schema.getType("Result") as GraphQLUnionType).types.map { it.name }, listOf("Node")) - assertEquals((schema.getType("SortResponse") as GraphQLUnionType).types.map { it.name }, listOf("NodeSortResponse")) - } - - class SingleMemberUnions : GraphQLQueryResolver { - fun search(): NodeSearchResponse? = null - - enum class NodeSortField { TITLE } - - enum class SortDirection { ASC, DESC } - - class Node(val id: String, val title: String?) - - class NodeSortResponse(val field: NodeSortField?, val direction: SortDirection?) - - class NodeSearchResponse(val results: List, val order: NodeSortResponse?) - } - - @Test - fun `scanner handles unions used only by fields of interfaces implemented by other interfaces`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - named: NamedNode - } - - interface Node { - content: Content - } - - interface NamedNode implements Node { - content: Text - name: String - } - - type Document implements NamedNode & Node { - content: Text - name: String - } - - type Text { - text: String - } - - type Image { - url: String - } - - union Content = Text | Image - """) - .resolvers(NestedInterfaceUnion()) - .dictionary(NestedInterfaceUnion.Document::class, NestedInterfaceUnion.Image::class) - .build() - .makeExecutableSchema() - - assertEquals((schema.getType("Content") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Text", "Image")) - } - - class NestedInterfaceUnion : GraphQLQueryResolver { - fun named(): NamedNode? = null - - interface NamedNode - - class Document(val content: Text?, val name: String?) : NamedNode - - class Text(val text: String?) - - class Image(val url: String?) - } - - @Test - fun `scanner handles unions used only by fields of interfaces discovered later in the scan`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - container: Container - } - - interface Container { - entry: Entry - } - - interface Entry { - content: Content - } - - type Box implements Container { - entry: Book - } - - type Book implements Entry { - content: Text - } - - type Text { - text: String - } - - type Image { - url: String - } - - union Content = Text | Image - """) - .resolvers(ChainedInterfaceUnion()) - .dictionary(ChainedInterfaceUnion.Box::class, ChainedInterfaceUnion.Image::class) - .build() - .makeExecutableSchema() - - assertEquals((schema.getType("Content") as GraphQLUnionType).types.map { it.name }.toSet(), setOf("Text", "Image")) - } - - class ChainedInterfaceUnion : GraphQLQueryResolver { - fun container(): Container? = null - - interface Container - - class Box(val entry: Book?) : Container - - class Book(val content: Text?) - - class Text(val text: String?) - - class Image(val url: String?) - } - - @Test - fun `scanner handles interfaces used only by interface fields`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - container: Container - } - - interface Container { - entry: Entry - } - - interface Entry { - id: ID! - } - - interface Draft implements Entry { - id: ID! - } - - type Box implements Container { - entry: Draft - } - """) - .resolvers(InterfaceOnlyInterface()) - .dictionary(InterfaceOnlyInterface.Box::class) - .build() - .makeExecutableSchema() - - assert(schema.getType("Entry") is GraphQLInterfaceType) - } - - class InterfaceOnlyInterface : GraphQLQueryResolver { - fun container(): Container? = null - - interface Container - - interface Draft - - class Box(val entry: Draft?) : Container - } - - @Test - fun `scanner finds members of unions used by interface fields through dictionary types first`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - item: Item - other: OtherUnion - } - - interface Item { - content: Content - } - - type Book implements Item { - content: Text - } - - type Text { - text: String - } - - type Image { - url: String - } - - union Content = Text | Image - - union OtherUnion = Holder - - type Holder { - image: Image - content: Content - } - """) - .resolvers(DictionaryReachableUnion()) - .dictionary(DictionaryReachableUnion.Book::class, DictionaryReachableUnion.Holder::class) - .build() - .makeExecutableSchema() - - val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { - """ - query { - item { - content { - ... on Text { text } - } - } - other { - ... on Holder { - image { url } - content { - ... on Image { url } - } - } - } - } - """ - } - - assertEquals(data["item"], mapOf("content" to mapOf("text" to "x"))) - assertEquals(data["other"], mapOf("image" to mapOf("url" to "u"), "content" to mapOf("url" to "v"))) - } - - class DictionaryReachableUnion : GraphQLQueryResolver { - fun item(): Item = Book(Text("x")) - fun other(): OtherUnion = Holder(Image("u"), Image("v")) - - interface Item - - interface Content - - interface OtherUnion - - class Book(val content: Text?) : Item - - class Text(val text: String?) : Content - - class Image(val url: String?) : Content - - class Holder(val image: Image?, val content: Content?) : OtherUnion - } - - @Test - fun `scanner finds implementors of interfaces used by interface fields through dictionary types first`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - wrapper: Wrapper - owner: Owner - } - - union Wrapper = Shelf - - type Shelf { - item: Item - book: Book - magazine: Magazine - } - - interface Item { - title: String - } - - type Book implements Item { - title: String - } - - type Magazine implements Item { - title: String - } - - interface Owner { - item: Item - } - """) - .resolvers(DictionaryReachableInterface()) - .dictionary(DictionaryReachableInterface.Shelf::class) - .build() - .makeExecutableSchema() - - assertEquals(schema.getImplementations(schema.getType("Item") as GraphQLInterfaceType)!!.map { it.name }.toSet(), setOf("Book", "Magazine")) - } - - class DictionaryReachableInterface : GraphQLQueryResolver { - fun wrapper(): Wrapper? = null - fun owner(): Owner? = null - - interface Wrapper - - interface Owner - - interface Item - - class Shelf(val item: Item?, val book: Book?, val magazine: Magazine?) : Wrapper - - class Book(val title: String?) : Item - - class Magazine(val title: String?) : Item - } - - @Test - fun `scanner finds members of unions used by interface fields through unused types when option is true`() { - val schema = SchemaParser.newParser() - .schemaString( - """ - type Query { - item: Item - } - - interface Item { - content: Content - } - - type Book implements Item { - content: Text - } - - type Text { - text: String - } - - type Image { - url: String - caption: Caption - } - - type Caption { - value: String - } - - union Content = Text | Image | Caption - """) - .resolvers(UnusedTypesReachableUnion()) - .dictionary(UnusedTypesReachableUnion.Book::class, UnusedTypesReachableUnion.Image::class) - .options(SchemaParserOptions.newOptions().includeUnusedTypes(true).build()) - .build() - .makeExecutableSchema() - - val data = assertNoGraphQlErrors(GraphQL.newGraphQL(schema).build()) { - """ - query { - item { - content { - ... on Text { text } - } - } - } - """ - } - - assertEquals(data["item"], mapOf("content" to mapOf("text" to "t"))) - } - - class UnusedTypesReachableUnion : GraphQLQueryResolver { - fun item(): Item = Book(Text("t")) - - interface Item - - interface Content - - class Book(val content: Text?) : Item - - class Text(val text: String?) : Content - - class Image(val url: String?, val caption: Caption?) : Content - - class Caption(val value: String?) : Content - } }