aPureBase / KGraphQL

Pure Kotlin GraphQL implementation
https://kgraphql.io
MIT License
307 stars 59 forks source link

Support for auto resolving generic types #210

Open IceBlizz6 opened 1 year ago

IceBlizz6 commented 1 year ago

I want to suggest a possible way for the library to handle generic types without requiring behavior that is explicitly defined by the user.

I've been messing around a bit with the codebase and it seems like the problem is that the lookup in some caches and functions is KClass<*>. KType.jvmErasure will erase the provided generic type.

We need to retain the information in the KType object. So i tried to replace the lookup type with a combination of KType and KClass<*> and the result seems promising. I think maybe just KType may be enough but my understanding of the codebase is still very limited.

I'm not entirely sure how this would be combined with the existing user provided generic type resolver. I think we could run it through the provided type resolver first and then let the system process it after if the returned type still has atleast one generic type parameter.

Idea here is that we construct a new type for each combination of the provided generic type. MyGenericType\<MyUser> becomes MyGenericType_MyUser MyGenericType\<MyDirectory> becomes MyGenericType_MyDirectory

class MyGenericType<T>(
    val name: String,
    val value: T
)

class MyUser(
    val name: String,
    val id: Int
)

class MyDirectory(
    val name: String,
    val id: Int
)

private fun a(): MyGenericType<MyUser> {
    TODO()
}

private fun b(): MyGenericType<MyDirectory> {
    TODO()
}

private fun c(): MyGenericType<MyUser> {
    TODO()
}
@Test
fun `generic type handling`() {
    val testedSchema = defaultSchema {
        query("myTest1") {
            resolver<MyGenericType<MyUser>> {
                a()
            }
        }
        query("myTest2") {
            resolver<MyGenericType<MyDirectory>> {
                b()
            }
        }
        query("myTest3") {
            resolver<MyGenericType<MyUser>> {
                c()
            }
        }
    }
    val typeNames = testedSchema.model.types.map { it.name }
    typeNames.forEach { println(it) }
}

Ouput

Schema Directive InputValue Type EnumValue Field Type Type Directive MyGenericType_MyUser MyUser MyGenericType_MyDirectory MyDirectory Schema TypeKind DirectiveLocation String Boolean Float Short Int Long Query

I was considering a pull request myself but i think my code is just a bit too messy.

tiagonuneslx commented 1 year ago

I'm very much interested in this feature as well. I am currently studying the library codebase with the same exact purpose.

My main use case is I want a resolver to return a Connection of a generic type:

data class Connection<T>(
    val totalCount: Int,
    val edges: List<Edge<T>>,
    val pageInfo: PageInfo
) {
    data class Edge<T>(
        val node: T,
        val cursor: Int
    )

    data class PageInfo(
        val endCursor: Int,
        val hasNextPage: Boolean
    )
}

I really like the design you're proposing, as a starting point!

In this case, if I wanted a resolver to return a Connection<User>, two types would be added to the Schema:

And eventually, we could add support for multiple generics. Then we could specify the type of cursor with Connection<T, C>. Then, we would have these types added to the Schema when returning a Connection<User, Int>

I really, really hope we're able to achieve this šŸ™‚

Edit: Of course the syntax would need to be revised if we wanted to support deeper level generics (e.g. Connection<Entity<User>, Int>>), but as a starting point it looks good to me.

tiagonuneslx commented 1 year ago

Progress Update

The main issue is that getting the actual returnType of a KProperty with a generic returnType is not trivial.

e.g.

val kType = typeOf<Connection<Person<String>, Int>>()
println(kType) // Connection<Person<String>, Int> āœ…
(kType.classifier as KClass<*>).memberProperties.forEach { property ->
    println(property.returnType) // List<Edge<T, K>> āŒ Should be List<Edge<String, Int>>
}

Unfortunately, kotlin reflection is still very limited, and this is something that's not yet supported.

I've been studying how other projects using reflection deal with this issue:

These libraries, using java reflection and Super Type Tokens pattern, are able to resolve a ParameterizedType to a java.lang.reflect.Type with the actual type parameters.

I chose to experiment with the Moshi approach, copying everything that was necessary to resolve a property returnType.

Sadly, there is no way to resolve to a KType, only to a java.lang.reflect.Type. All these libraries I studied work exclusively with java.lang.reflect.Type under the hood.

This differs from the KGraphQL library, which associates definitions by KClass<*>, and relies on KType and KClass in many places to compile a schema. We'll never be able to work with generics while we associate by KClass<*>, because it doesn't hold information about type parameters. We also can't rely on KType, because for compiling properties with generic returnType, we don't have access to a KType with resolved type parameters (only to a java.lang.reflect.Type with resolved type parameters).

Conclusion: For this to work, internal changes, mainly to the compilation and definitions, need to be done to rely exclusively on java.lang.reflect.Type.

Note: I have tried very hard to find a way to get the properties returnTypes as KType with resolved type parameters, so that we could associate definitions by KType and keep compiling using KType and KClass<*>, but it's not possible (I'd love for someone to prove me wrong).

I am working on these changes and I've made a quick POF, pushed to this branch. It's still lacking a lot, but I'd say the results are already very promising:

class Connection<T : Any, K : Any>(
    val totalCount: Int,
    val edges: List<Edge<T, K>>,
    val pageInfo: PageInfo<K>
) {
    class Edge<T : Any, K: Any>(
        val node: T,
        val cursor: K
    )

    class PageInfo<K: Any>(
        val endCursor: K,
        val hasNextPage: Boolean
    )
}

val names = listOf("Kai", "Eliana", "Jayden", "Ezra", "Luca", "Rowan", "Nova", "Amara")

fun main() {
    val schema = KGraphQL.schema {
        configure {
            this.useDefaultPrettyPrinter = true
        }
        query("names") {
            resolver { ->
                Connection(
                    totalCount = names.size,
                    edges = names.subList(0, 2).mapIndexed { index, name ->
                        Connection.Edge(
                            node = name,
                            cursor = index
                        )
                    },
                    pageInfo = Connection.PageInfo(
                        endCursor = 1,
                        hasNextPage = true
                    )
                )
            }.returns<Connection<String, Int>>()
        }
    }

    schema.types.forEach { println(it.name) }
    println()
    println(schema.executeBlocking("{ names { totalCount, edges { node, cursor }, pageInfo { endCursor, hasNextPage } } }"))

Console:

__Schema
__Directive
__InputValue
__Type
__EnumValue
__Field
Connection<String, Integer>
Edge<String, Integer>
PageInfo<Integer>
__TypeKind
__DirectiveLocation
String
Boolean
Float
Short
Int
Long
Query

{
  "data" : {
    "names" : {
      "totalCount" : 8,
      "edges" : [ {
        "node" : "Kai",
        "cursor" : 0
      }, {
        "node" : "Eliana",
        "cursor" : 1
      } ],
      "pageInfo" : {
        "endCursor" : 1,
        "hasNextPage" : true
      }
    }
  }
}
afgarcia86 commented 1 year ago

This would be great to see

IceBlizz6 commented 1 year ago

This would be great to see

I think i could finish this, But the project looks pretty dead at the moment. I've been waiting a long time for subscription support which i'm not sure is in development anymore.

If the future looked promising then i would love to assist more, but as it stands i have started to look into alternatives like ExpediaGroup/graphql-kotlin.