diff --git a/README.md b/README.md index e49edfb5..ddd021d6 100644 --- a/README.md +++ b/README.md @@ -223,12 +223,18 @@ Then on the data class: 4. `method getField(*fieldArgs [, DataFetchingEnvironment])` 5. `field ` +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()`. diff --git a/src/main/kotlin/com/coxautodev/graphql/tools/FieldResolverScanner.kt b/src/main/kotlin/com/coxautodev/graphql/tools/FieldResolverScanner.kt index e4f9967b..cc710d2e 100644 --- a/src/main/kotlin/com/coxautodev/graphql/tools/FieldResolverScanner.kt +++ b/src/main/kotlin/com/coxautodev/graphql/tools/FieldResolverScanner.kt @@ -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 } diff --git a/src/main/kotlin/com/coxautodev/graphql/tools/PropertyFieldResolver.kt b/src/main/kotlin/com/coxautodev/graphql/tools/PropertyFieldResolver.kt index e6e77b14..181e3751 100644 --- a/src/main/kotlin/com/coxautodev/graphql/tools/PropertyFieldResolver.kt +++ b/src/main/kotlin/com/coxautodev/graphql/tools/PropertyFieldResolver.kt @@ -14,7 +14,13 @@ internal class PropertyFieldResolver(field: FieldDefinition, search: FieldResolv } override fun scanForMatches(): List { - 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}" diff --git a/src/main/kotlin/com/coxautodev/graphql/tools/PropertyMapResolver.kt b/src/main/kotlin/com/coxautodev/graphql/tools/PropertyMapResolver.kt new file mode 100644 index 00000000..3bfb0e15 --- /dev/null +++ b/src/main/kotlin/com/coxautodev/graphql/tools/PropertyMapResolver.kt @@ -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 { + 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 { + 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") + } + } +} diff --git a/src/main/kotlin/com/coxautodev/graphql/tools/SchemaClassScanner.kt b/src/main/kotlin/com/coxautodev/graphql/tools/SchemaClassScanner.kt index 9ab6135e..d8dfaada 100644 --- a/src/main/kotlin/com/coxautodev/graphql/tools/SchemaClassScanner.kt +++ b/src/main/kotlin/com/coxautodev/graphql/tools/SchemaClassScanner.kt @@ -133,7 +133,13 @@ internal class SchemaClassScanner(initialDictionary: BiMap>, al // Union types can also be excluded, as their possible types are resolved recursively later val dictionary = try { Maps.unmodifiableBiMap(HashBiMap.create, 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) @@ -210,17 +216,31 @@ internal class SchemaClassScanner(initialDictionary: BiMap>, 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) @@ -267,6 +287,9 @@ internal class SchemaClassScanner(initialDictionary: BiMap>, 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()}") } } @@ -424,7 +447,7 @@ internal class SchemaClassScanner(initialDictionary: BiMap>, 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" } diff --git a/src/test/groovy/com/coxautodev/graphql/tools/EndToEndSpec.groovy b/src/test/groovy/com/coxautodev/graphql/tools/EndToEndSpec.groovy index ad6c98a6..c3f9ccd1 100644 --- a/src/test/groovy/com/coxautodev/graphql/tools/EndToEndSpec.groovy +++ b/src/test/groovy/com/coxautodev/graphql/tools/EndToEndSpec.groovy @@ -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) 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) { diff --git a/src/test/kotlin/com/coxautodev/graphql/tools/EndToEndSpec.kt b/src/test/kotlin/com/coxautodev/graphql/tools/EndToEndSpec.kt index 78fc82bd..07c5d285 100644 --- a/src/test/kotlin/com/coxautodev/graphql/tools/EndToEndSpec.kt +++ b/src/test/kotlin/com/coxautodev/graphql/tools/EndToEndSpec.kt @@ -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() @@ -63,6 +65,12 @@ type Query { class: [Item!] hashCode: [Item!] + propertyHashMapItems: [PropertyHashMapItem!] + propertyMapMissingNamePropItems: [PropertyHashMapItem!] + propertySortedMapItems: [PropertySortedMapItem!] + propertyMapWithComplexItems: [PropertyMapWithComplexItem!] + propertyMapWithNestedComplexItems: [PropertyMapWithNestedComplexItem!] + propertyField: String! dataFetcherResult: Item! } @@ -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 @@ -169,6 +209,27 @@ val thirdItems = mutableListOf( ThirdItem(100) ) +val propetyHashMapItems = mutableListOf( + hashMapOf("name" to "bob", "age" to 55) +) + +val propertyMapMissingNamePropItems = mutableListOf( + hashMapOf("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() { fun isEmpty() = items.isEmpty() fun allBaseItems() = items @@ -201,6 +262,13 @@ class Query: GraphQLQueryResolver, ListListResolver() { 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 { @@ -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) : 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)