Skip to content

Commit a151a5e

Browse files
authored
Merge pull request #851 from graphql-java-kickstart/bugfix/465
Let input types share a dictionary class
2 parents 418b804 + 446416b commit a151a5e

5 files changed

Lines changed: 236 additions & 3 deletions

File tree

‎src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import java.lang.reflect.Method
1616
* @author Andrew Potter
1717
*/
1818
internal class SchemaClassScanner(
19-
initialDictionary: BiMap<String, Class<*>>,
19+
initialDictionary: Map<String, Class<*>>,
2020
allDefinitions: List<Definition<*>>,
2121
resolvers: List<GraphQLResolver<*>>,
2222
private val scalars: CustomScalarMap,
@@ -59,6 +59,17 @@ internal class SchemaClassScanner(
5959
}
6060
}
6161

62+
// Input object types never go into the type => class bimap that interface and union type resolution use, so several of
63+
// them can share a class, e.g. ObjectNode
64+
initialDictionary
65+
.filterKeys { definitionsByName[it] !is InputObjectTypeDefinition }
66+
.entries.groupBy({ it.value }, { it.key })
67+
.forEach { (clazz, names) ->
68+
if (names.size > 1) {
69+
throw SchemaClassScannerError("Class in supplied dictionary '${clazz.name}' specified type names ${names.joinToString { "'$it'" }}, but only input object types can share a class!")
70+
}
71+
}
72+
6273
if (options.allowUnimplementedResolvers) {
6374
log.warn("Option 'allowUnimplementedResolvers' should only be set to true during development, as it can cause schema errors to be moved to query time instead of schema creation time. Make sure this is turned off in production.")
6475
}

‎src/main/kotlin/graphql/kickstart/tools/SchemaParserDictionary.kt‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ import kotlin.reflect.KClass
55

66
class SchemaParserDictionary {
77

8-
private val dictionary: BiMap<String, Class<*>> = BiMap.create()
8+
// Not a BiMap, since several input types can share a class, e.g. ObjectNode. The scanner checks that other types don't.
9+
private val dictionary = linkedMapOf<String, Class<*>>()
910

10-
fun getDictionary(): BiMap<String, Class<*>> = BiMap.unmodifiableBiMap(dictionary)
11+
/**
12+
* Returns a copy of the entries added so far. If several types share a class, the inverse of the copy only maps that class
13+
* back to the first of them.
14+
*/
15+
fun getDictionary(): BiMap<String, Class<*>> = BiMap.unmodifiableCopyOf(dictionary)
1116

1217
/**
1318
* Add arbitrary classes to the parser's dictionary, overriding the generated type name.

‎src/main/kotlin/graphql/kickstart/tools/util/BiMap.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package graphql.kickstart.tools.util;
22

33
import java.util.HashMap;
4+
import java.util.LinkedHashMap;
45
import java.util.Map;
56
import java.util.Objects;
67
import java.util.Set;
@@ -21,6 +22,17 @@ public static <K, V> BiMap<K, V> unmodifiableBiMap(BiMap<K, V> biMap) {
2122
return new BiMap<>(unmodifiableMap(biMap.delegate), unmodifiableMap(biMap.inverse));
2223
}
2324

25+
/**
26+
* Returns an unmodifiable bimap with the entries of the given map. Unlike {@link #put(Object, Object)}, several keys may
27+
* have the same value. The inverse only maps such a value back to the first of its keys, so the inverse and
28+
* {@link #values()} can have fewer entries than the bimap.
29+
*/
30+
public static <K, V> BiMap<K, V> unmodifiableCopyOf(Map<K, V> map) {
31+
Map<V, K> inverse = new HashMap<>();
32+
map.forEach((key, value) -> inverse.putIfAbsent(value, key));
33+
return new BiMap<>(unmodifiableMap(new LinkedHashMap<>(map)), unmodifiableMap(inverse));
34+
}
35+
2436
private BiMap(Map<K, V> delegate, Map<V, K> inverse) {
2537
this.delegate = delegate;
2638
this.inverse = inverse;
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package graphql.kickstart.tools
2+
3+
import com.fasterxml.jackson.databind.node.ObjectNode
4+
import graphql.GraphQL
5+
import org.junit.Assert.assertThrows
6+
import org.junit.Test
7+
8+
class SchemaParserDictionaryTest {
9+
10+
@Test
11+
fun `input types can share a dictionary class`() {
12+
val schema = SchemaParser.newParser()
13+
.schemaString(
14+
"""
15+
type Query {
16+
create(input: EntityCreateInput!): String
17+
update(input: EntityUpdateInput!): String
18+
}
19+
20+
input EntityCreateInput {
21+
name: String
22+
a: NestedA
23+
}
24+
25+
input EntityUpdateInput {
26+
id: ID!
27+
b: [NestedB!]
28+
}
29+
30+
input NestedA {
31+
x: Int
32+
}
33+
34+
input NestedB {
35+
y: Int
36+
}
37+
""")
38+
.resolvers(object : GraphQLQueryResolver {
39+
fun create(input: ObjectNode): String = input.toString()
40+
fun update(input: ObjectNode): String = input.toString()
41+
})
42+
.dictionary("EntityCreateInput", ObjectNode::class.java)
43+
.dictionary("EntityUpdateInput", ObjectNode::class.java)
44+
// ObjectNode has no properties, so the classes of the nested inputs come from the dictionary
45+
.dictionary("NestedA", ObjectNode::class.java)
46+
.dictionary("NestedB", ObjectNode::class.java)
47+
.build()
48+
.makeExecutableSchema()
49+
val gql = GraphQL.newGraphQL(schema).build()
50+
51+
val data = assertNoGraphQlErrors(gql) {
52+
"""
53+
query {
54+
create(input: { name: "n", a: { x: 1 } })
55+
update(input: { id: "2", b: [{ y: 3 }] })
56+
}
57+
"""
58+
}
59+
60+
assertEquals(data["create"], """{"name":"n","a":{"x":1}}""")
61+
assertEquals(data["update"], """{"id":"2","b":[{"y":3}]}""")
62+
}
63+
64+
@Test
65+
fun `directive input types sharing a class can be passed to the builder from a SchemaParserDictionary`() {
66+
val dictionary = SchemaParserDictionary()
67+
.add("AInput", Map::class)
68+
.add("BInput", Map::class)
69+
70+
assertEquals(dictionary.getDictionary().toMap(), mapOf("AInput" to Map::class.java, "BInput" to Map::class.java))
71+
72+
// graphql-spring-boot reads a SchemaParserDictionary bean this way
73+
val schema = SchemaParser.newParser()
74+
.schemaString(
75+
"""
76+
directive @a(arg: AInput) on FIELD_DEFINITION
77+
directive @b(arg: BInput) on FIELD_DEFINITION
78+
79+
type Query {
80+
test: String @a(arg: { x: 1 }) @b(arg: { y: "b" })
81+
}
82+
83+
input AInput {
84+
x: Int
85+
}
86+
87+
input BInput {
88+
y: String
89+
}
90+
""")
91+
.resolvers(object : GraphQLQueryResolver {
92+
fun test(): String? = null
93+
})
94+
.dictionary(dictionary.getDictionary())
95+
.build()
96+
.makeExecutableSchema()
97+
98+
val field = schema.queryType.getFieldDefinition("test")
99+
assertEquals(field.getAppliedDirective("a").getArgument("arg")?.getValue<Map<*, *>>(), mapOf("x" to 1))
100+
assertEquals(field.getAppliedDirective("b").getArgument("arg")?.getValue<Map<*, *>>(), mapOf("y" to "b"))
101+
}
102+
103+
@Test
104+
fun `input type and object type can share a dictionary class`() {
105+
// DogInput is only used by a directive, so its class can only come from the dictionary
106+
val schema = SchemaParser.newParser()
107+
.schemaString(
108+
"""
109+
directive @d(arg: DogInput) on FIELD_DEFINITION
110+
111+
type Query {
112+
dog: Dog @d(arg: { name: "z" })
113+
}
114+
115+
interface Dog {
116+
name: String
117+
}
118+
119+
type LabradorRetriever implements Dog {
120+
name: String
121+
}
122+
123+
input DogInput {
124+
name: String
125+
}
126+
""")
127+
.resolvers(object : GraphQLQueryResolver {
128+
fun dog(): Dog = LabradorRetriever(name = "a", color = "black", fluffiness = 1, speed = 1f)
129+
})
130+
.dictionary("LabradorRetriever", LabradorRetriever::class)
131+
.dictionary("DogInput", LabradorRetriever::class)
132+
.build()
133+
.makeExecutableSchema()
134+
135+
val field = schema.queryType.getFieldDefinition("dog")
136+
assertEquals(field.getAppliedDirective("d").getArgument("arg")?.getValue<Map<*, *>>(), mapOf("name" to "z"))
137+
138+
val gql = GraphQL.newGraphQL(schema).build()
139+
val data = assertNoGraphQlErrors(gql) {
140+
"""
141+
query {
142+
dog {
143+
__typename
144+
name
145+
}
146+
}
147+
"""
148+
}
149+
150+
assertEquals(data["dog"], mapOf("__typename" to "LabradorRetriever", "name" to "a"))
151+
}
152+
153+
@Test
154+
fun `object types can't share a dictionary class`() {
155+
val error = assertThrows(SchemaClassScannerError::class.java) {
156+
SchemaParser.newParser()
157+
.schemaString(
158+
"""
159+
type Query {
160+
a: A
161+
b: B
162+
}
163+
164+
type A {
165+
id: Int
166+
}
167+
168+
type B {
169+
id: Int
170+
}
171+
172+
input AInput {
173+
id: Int
174+
}
175+
""")
176+
.resolvers(object : GraphQLQueryResolver {
177+
fun a(): ThirdItem? = null
178+
fun b(): ThirdItem? = null
179+
})
180+
.dictionary("A", ThirdItem::class)
181+
.dictionary("AInput", ThirdItem::class)
182+
.dictionary("B", ThirdItem::class)
183+
.build()
184+
}
185+
186+
// the input type sharing the class isn't part of the conflict
187+
assertEquals(error.message, "Class in supplied dictionary '${ThirdItem::class.java.name}' specified type names 'A', 'B', " +
188+
"but only input object types can share a class!")
189+
}
190+
}

‎src/test/kotlin/graphql/kickstart/tools/util/BiMapTest.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,19 @@ class BiMapTest {
6767
val expected = setOf(1, 2, 3)
6868
assertEquals(expected, values)
6969
}
70+
71+
@Test
72+
fun `unmodifiable copy should keep keys with the same value`() {
73+
val copy = BiMap.unmodifiableCopyOf(linkedMapOf("foo" to 1, "bar" to 2, "baz" to 1))
74+
75+
assertEquals(mapOf("foo" to 1, "bar" to 2, "baz" to 1), copy.toMap())
76+
assertEquals(listOf("foo", "bar", "baz"), copy.keys.toList())
77+
assertEquals(setOf(1, 2), copy.values)
78+
assertEquals(mapOf(1 to "foo", 2 to "bar"), copy.inverse().toMap())
79+
}
80+
81+
@Test(expected = UnsupportedOperationException::class)
82+
fun `unmodifiable copy should not permit changes`() {
83+
BiMap.unmodifiableCopyOf(mapOf("foo" to 1))["bar"] = 2
84+
}
7085
}

0 commit comments

Comments
 (0)