Skip to content

Issue #113: Support for PropertyDataFetcher behavior #191

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,18 @@ Then on the data class:
4. `method getField<Name>(*fieldArgs [, DataFetchingEnvironment])`
5. `field <name>`

Last of all, if the data class implements`java.util.Map` then:
1. `method get(name)`


*Note:* All reflection discovery is done on startup, and runtime reflection method calls use [reflectasm](https://github.com/EsotericSoftware/reflectasm), which increases performance and unifies stacktraces. No more `InvocationTargetException`!

*Note:* `java.util.Optional` can be used for nullable field arguments and nullable return values, and the schema parser will verify that it's not used with non-null field arguments and return values.

*Note:* Methods on `java.lang.Object` are excluded from method matching, for example a field named `class` will require a method named `getFieldClass` defined.

*Note:* If one of the values of a type backed by a `java.util.Map` is non-scalar then this type will need to be added to the `type dictionary` (see below). After adding this type to the dictionary, GraphQL Java Tools will however still be able to find the types used in the fields of this added type.

### Enum Types

Enum values are automatically mapped by `Enum#name()`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ internal class FieldResolverScanner(val options: SchemaParserOptions) {
}
}

if(java.util.Map::class.java.isAssignableFrom(search.type.unwrap())) {
return PropertyMapResolver(field, search, options, search.type.unwrap())
}

return null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ internal class PropertyFieldResolver(field: FieldDefinition, search: FieldResolv
}

override fun scanForMatches(): List<TypeClassMatcher.PotentialMatch> {
return listOf(TypeClassMatcher.PotentialMatch.returnValue(field.type, property.genericType, genericType, SchemaClassScanner.FieldTypeReference(property), false))
return listOf(
TypeClassMatcher.PotentialMatch.returnValue(
field.type,
property.genericType,
genericType,
SchemaClassScanner.FieldTypeReference(property.toString()),
false))
}

override fun toString() = "PropertyFieldResolver{property=$property}"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.coxautodev.graphql.tools

import graphql.language.FieldDefinition
import graphql.schema.DataFetcher
import graphql.schema.DataFetchingEnvironment
import java.lang.reflect.ParameterizedType

/**
* @author Nick Weedon
*
* The PropertyMapResolver implements the Map (i.e. property map) specific portion of the logic within the GraphQL PropertyDataFetcher class.
*/
internal class PropertyMapResolver(field: FieldDefinition, search: FieldResolverScanner.Search, options: SchemaParserOptions, relativeTo: JavaType): FieldResolver(field, search, options, relativeTo) {

var mapGenericValue : JavaType = getMapGenericType(relativeTo)

fun getMapGenericType(mapClass : JavaType) : JavaType {
if(mapClass is ParameterizedType) {
return mapClass.actualTypeArguments[1]
} else {
return Object::class.java
}
}

override fun createDataFetcher(): DataFetcher<*> {
return PropertyMapResolverDataFetcher(getSourceResolver(), field.name)
}

override fun scanForMatches(): List<TypeClassMatcher.PotentialMatch> {
return listOf(TypeClassMatcher.PotentialMatch.returnValue(field.type, mapGenericValue, genericType, SchemaClassScanner.FieldTypeReference(field.name), false))
}

override fun toString() = "PropertyMapResolverDataFetcher{key=${field.name}}"
}

class PropertyMapResolverDataFetcher(private val sourceResolver: SourceResolver, val key : String): DataFetcher<Any> {
override fun get(environment: DataFetchingEnvironment): Any? {
val resolvedSourceObject = sourceResolver(environment)
if(resolvedSourceObject is Map<*, *>) {
return resolvedSourceObject[key]
} else {
throw RuntimeException("PropertyMapResolverDataFetcher attempt to fetch a field from an object instance that was not a map")
}
}
}
35 changes: 29 additions & 6 deletions src/main/kotlin/com/coxautodev/graphql/tools/SchemaClassScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,13 @@ internal class SchemaClassScanner(initialDictionary: BiMap<String, Class<*>>, al
// Union types can also be excluded, as their possible types are resolved recursively later
val dictionary = try {
Maps.unmodifiableBiMap(HashBiMap.create<TypeDefinition<*>, JavaType>().also {
dictionary.filter { it.value.javaType != null && it.key !is InputObjectTypeDefinition && it.key !is UnionTypeDefinition }.mapValuesTo(it) { it.value.javaType }
dictionary.filter {
it.value.javaType != null
&& it.value.typeClass() != java.lang.Object::class.java
&& !java.util.Map::class.java.isAssignableFrom(it.value.typeClass())
&& it.key !is InputObjectTypeDefinition
&& it.key !is UnionTypeDefinition
}.mapValuesTo(it) { it.value.javaType }
})
} catch (t: Throwable) {
throw SchemaClassScannerError("Error creating bimap of type => class", t)
Expand Down Expand Up @@ -210,17 +216,31 @@ internal class SchemaClassScanner(initialDictionary: BiMap<String, Class<*>>, al
}
}

private fun getResolverInfoFromTypeDictionary(typeName: String) : ResolverInfo? {
val dictionaryType = initialDictionary[typeName]?.get()
return if(dictionaryType != null) {
resolverInfosByDataClass[dictionaryType] ?: DataClassResolverInfo(dictionaryType);
} else {
null
}
}

/**
* Scan a new object for types that haven't been mapped yet.
*/
private fun scanQueueItemForPotentialMatches(item: QueueItem) {
val resolverInfoList = this.resolverInfos.filter { it.dataClassType == item.clazz }
val resolverInfo: ResolverInfo

resolverInfo = if (resolverInfoList.size > 1) {
val resolverInfo: ResolverInfo? = if (resolverInfoList.size > 1) {
MultiResolverInfo(resolverInfoList)
} else {
resolverInfosByDataClass[item.clazz] ?: DataClassResolverInfo(item.clazz)
if(item.clazz.equals(Object::class.java)) {
getResolverInfoFromTypeDictionary(item.type.name)
} else {
resolverInfosByDataClass[item.clazz] ?: DataClassResolverInfo(item.clazz)
}
}
if(resolverInfo == null) {
throw throw SchemaClassScannerError("The GraphQL schema type '${item.type.name}' maps to a field of type java.lang.Object however there is no matching entry for this type in the type dictionary. You may need to add this type to the dictionary before building the schema.")
}

scanResolverInfoForPotentialMatches(item.type, resolverInfo)
Expand Down Expand Up @@ -267,6 +287,9 @@ internal class SchemaClassScanner(initialDictionary: BiMap<String, Class<*>>, al
if (options.preferGraphQLResolver && realEntry.hasResolverRef()) {
log.warn("The real entry ${realEntry.joinReferences()} is a GraphQLResolver so ignoring this one ${javaType.unwrap()} $reference")
} else {
if(java.util.Map::class.java.isAssignableFrom(javaType.unwrap())) {
throw SchemaClassScannerError("Two different property map classes used for type ${type.name}:\n${realEntry.joinReferences()}\n\n- ${javaType}:\n| ${reference.getDescription()}")
}
throw SchemaClassScannerError("Two different classes used for type ${type.name}:\n${realEntry.joinReferences()}\n\n- ${javaType.unwrap()}:\n| ${reference.getDescription()}")
}
}
Expand Down Expand Up @@ -424,7 +447,7 @@ internal class SchemaClassScanner(initialDictionary: BiMap<String, Class<*>>, al
override fun getDescription() = "parameter $index of method $method"
}

class FieldTypeReference(private val field: Field) : Reference() {
class FieldTypeReference(private val field: String) : Reference() {
override fun getDescription() = "type of field $field"
}

Expand Down
102 changes: 102 additions & 0 deletions src/test/groovy/com/coxautodev/graphql/tools/EndToEndSpec.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,108 @@ class EndToEndSpec extends Specification {
data.itemByUUID
}

def "generated schema should handle any java.util.Map (using HashMap) types as property maps"() {
when:
def data = Utils.assertNoGraphQlErrors(gql) {
'''
{
propertyHashMapItems {
name
age
}
}
'''
}

then:
data.propertyHashMapItems == [ [name: "bob", age:55] ]
}

def "generated schema should handle any java.util.Map (using SortedMap) types as property maps"() {
when:
def data = Utils.assertNoGraphQlErrors(gql) {
'''
{
propertySortedMapItems {
name
age
}
}
'''
}

then:
data.propertySortedMapItems == [ [name: "Arthur", age:76], [name: "Jane", age:28] ]
}

// In this test a dictionary entry for the schema type ComplexMapItem is defined
// so that it is possible for a POJO mapping to be known since the ComplexMapItem is contained
// in a property map (i.e. Map<String, Object>) and so the normal resolver and schema traversal code
// will not be able to find the POJO since it does not exist as a strongly typed object in
// resolver/POJO graph.
def "generated schema should handle java.util.Map types as property maps when containing complex data"() {
when:
def data = Utils.assertNoGraphQlErrors(gql) {
'''
{
propertyMapWithComplexItems {
nameId {
id
}
age
}
}
'''
}

then:
data.propertyMapWithComplexItems == [ [nameId:[id:150], age:72] ]
}

// This behavior is consistent with PropertyDataFetcher
def "property map returns null when a property is not defined."() {
when:
def data = Utils.assertNoGraphQlErrors(gql) {
'''
{
propertyMapMissingNamePropItems {
name
age
}
}
'''
}

then:
data.propertyMapMissingNamePropItems == [ [name: null, age:55] ]
}

// In this test a dictonary entry for the schema type NestedComplexMapItem is defined
// however we expect to not be required to define one for the transitive UndiscoveredItem object since
// the schema resolver discovery code should still be able to automatically determine the POJO that
// maps to this schema type.
def "generated schema should continue to associate resolvers for transitive types of a java.util.Map complex data type"() {
when:
def data = Utils.assertNoGraphQlErrors(gql) {
'''
{
propertyMapWithNestedComplexItems {
nested {
item {
id
}
}
age
}
}
'''
}

then:
data.propertyMapWithNestedComplexItems == [ [ nested:[ item: [id:63] ], age:72] ]
}


def "generated schema should handle optional arguments"() {
when:
def data = Utils.assertNoGraphQlErrors(gql) {
Expand Down
71 changes: 71 additions & 0 deletions src/test/kotlin/com/coxautodev/graphql/tools/EndToEndSpec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ fun createSchema() = SchemaParser.newParser()
.scalars(customScalarUUID, customScalarMap, customScalarId)
.dictionary("OtherItem", OtherItemWithWrongName::class)
.dictionary("ThirdItem", ThirdItem::class)
.dictionary("ComplexMapItem", ComplexMapItem::class)
.dictionary("NestedComplexMapItem", NestedComplexMapItem::class)
.build()
.makeExecutableSchema()

Expand Down Expand Up @@ -63,6 +65,12 @@ type Query {
class: [Item!]
hashCode: [Item!]

propertyHashMapItems: [PropertyHashMapItem!]
propertyMapMissingNamePropItems: [PropertyHashMapItem!]
propertySortedMapItems: [PropertySortedMapItem!]
propertyMapWithComplexItems: [PropertyMapWithComplexItem!]
propertyMapWithNestedComplexItems: [PropertyMapWithNestedComplexItem!]

propertyField: String!
dataFetcherResult: Item!
}
Expand Down Expand Up @@ -144,6 +152,38 @@ type ThirdItem {
id: Int!
}

type PropertyHashMapItem {
name: String
age: Int!
}

type PropertySortedMapItem {
name: String!
age: Int!
}

type ComplexMapItem {
id: Int!
}

type UndiscoveredItem {
id: Int!
}

type NestedComplexMapItem {
item: UndiscoveredItem
}

type PropertyMapWithNestedComplexItem {
nested: NestedComplexMapItem!
age: Int!
}

type PropertyMapWithComplexItem {
nameId: ComplexMapItem!
age: Int!
}

union OtherUnion = Item | ThirdItem

union NestedUnion = OtherUnion | OtherItem
Expand All @@ -169,6 +209,27 @@ val thirdItems = mutableListOf(
ThirdItem(100)
)

val propetyHashMapItems = mutableListOf(
hashMapOf("name" to "bob", "age" to 55)
)

val propertyMapMissingNamePropItems = mutableListOf(
hashMapOf<String, kotlin.Any>("age" to 55)
)

val propetySortedMapItems = mutableListOf(
sortedMapOf("name" to "Arthur", "age" to 76),
sortedMapOf("name" to "Jane", "age" to 28)
)

val propertyMapWithComplexItems = mutableListOf(
hashMapOf("nameId" to ComplexMapItem(150), "age" to 72)
)

val propertyMapWithNestedComplexItems = mutableListOf(
hashMapOf("nested" to NestedComplexMapItem(UndiscoveredItem(63)), "age" to 72)
)

class Query: GraphQLQueryResolver, ListListResolver<String>() {
fun isEmpty() = items.isEmpty()
fun allBaseItems() = items
Expand Down Expand Up @@ -201,6 +262,13 @@ class Query: GraphQLQueryResolver, ListListResolver<String>() {
fun getFieldClass() = items
fun getFieldHashCode() = items

fun propertyHashMapItems() = propetyHashMapItems
fun propertyMapMissingNamePropItems() = propertyMapMissingNamePropItems
fun propertySortedMapItems() = propetySortedMapItems
fun propertyMapWithComplexItems() = propertyMapWithComplexItems
fun propertyMapWithNestedComplexItems() = propertyMapWithNestedComplexItems


private val propertyField = "test"

fun dataFetcherResult(): DataFetcherResult<Item> {
Expand Down Expand Up @@ -256,6 +324,9 @@ enum class Type { TYPE_1, TYPE_2 }
data class Item(val id: Int, override val name: String, override val type: Type, override val uuid:UUID, val tags: List<Tag>) : ItemInterface
data class OtherItemWithWrongName(val id: Int, override val name: String, override val type: Type, override val uuid:UUID) : ItemInterface
data class ThirdItem(val id: Int)
data class ComplexMapItem(val id: Int)
data class UndiscoveredItem(val id: Int)
data class NestedComplexMapItem(val item: UndiscoveredItem)
data class Tag(val id: Int, val name: String)
data class ItemSearchInput(val name: String)
data class NewItemInput(val name: String, val type: Type)
Expand Down