11

Generating a GraphQL Query Kotlin DSL with Annotations

 2 years ago
source link: https://proandroiddev.com/generating-a-graphql-query-dsl-with-kotlin-b4bfc9def9a8
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Annotation module:

In Android Studio, add a new module to your project and add choose Java or Kotlin library.
This module will contain one class, create an annotation class and give it a name, this class will be used to annotate data classes to specify that this class will be used as a query.

Specify that you want this annotation to be used only with classes and the will only be available in source code

Annotation

Codegen module:

This module also a Kotlin library, add a new module to your project and add choose Java or Kotlin library. We’ll do most of the work required to process the annotation we created and generate the required classes and functions.

add this plugin to the list of module plugins

plugins {
id 'kotlin-kapt'
}

Here we specify that we’ll use annotations in our module.

Then in the build.gradle file we’ll add these dependencies:

  1. We add the annotation project we created earlier so it’s visible in the codegen project.
  2. We add the auto.service dependencies that are responsible for the annotation processing logic.
  3. We add KotlinPoet to help us with code generation.

Create the query annotation processor:

@AutoService(Processor::class)
class QueryProcessor: AbstractProcessor() {

This is a processor class that is annotated with @AutoService(Processor::class) and extends AbstractProcessor()

Here we specify that this class needs to be processed during compilation to generate the necessary code.

The implementation is typical for annotation processor classes except with one small addition.

We specify the annotation we will work with by overriding getSupportedAnnotationTypes and passing the query annotation class we created.

override fun getSupportedAnnotationTypes(): MutableSet<String> = mutableSetOf(KLQuery::class.java.name)

Then we override process() the function that is responsible for processing the annotation elements.

override fun process(annotations: MutableSet<out TypeElement>?, roundEnv: RoundEnvironment): Boolean {...

Specify the generated files location, in this case, we’ll use kapt.kotlin.generated

val kaptKotlinGeneratedDir = processingEnv.options["kapt.kotlin.generated"] ?: return false

From the roundEnv we get the elements annotated with KLQuery roundEnv.getElementsAnnotatedWith(KLQuery::class.java)
and then we check if this element is, in fact, a class; else we print an error

Kind check

The final code should look like this:

query processor class

Before we move further, we need to create another class called QueryInfo:

QueryInfo

A data class that holds the query info will come in handy to pass the necessary information from the processor to the builder.

Now inside the QueryProcessor class, create a function and pass the annotation element from the process function and extract the needed values from it.

What are we looking for is the element class passed, its name and the package name

QueryInfo creator

Then we generate the actual file from the query info and write it to the kapt.kotlin.generated directory

File builder

A couple of important things is happening here; first, we add a generated class to this file by addType(QueryBuilder(queryInfo).buildClass())

Then we add a DSL function to the file outside of the class by
addFunction(QueryBuilder(queryInfo).buildDSL())

we’ll go through each function implementation when we work on the builder class.

This class now looks like this:

query processor with generators

Classes with declared types

The last step would have been enough if your classes only contain non-declared types, meaning that while a class is being generated, it could contain a type of generated class that has not been generated yet, resulting in the processing task failing.

A quick workaround is to check if the currently processed element contains declared types. If it does, add it to a queue and skip it until all non-declared typed elements are processed, then process the queue elements until it’s empty. -a better approach can be added here-

The final code for processing elements should look like this:

process function

Now for the code for hasDeclaredTypes implementation:

declared types check

By this far we are ready to build and generate the actual query class that we’ll use in our code.

Building a Query class

The query class's backing implementation is basically a StringBuilder, with each value being written inside the DSL function block is appended to the query instance.

Generated Code

Let's start with the class fields.

The general idea for creating a DSL “that looks like JavaScript code" while maintaining Kotlin statically typed nature was done by using this pattern:

Generated Code

How do we generate this with KotlinPoet?

KotlinPoet Code

Let’s go through the code:
1. We generate a class field with the same name we found in the original class, but the type is irrelevant, so it’s replaced with ‘Unit.’
2. We override the getter function to add our custom implementation. Basically, we want this function to be called each time that field is written inside the DSL function.
3. The actual function implementation is written with _ prefix since we are not interested in this function

The above pattern allows us to write id inside a DSL function and expect id\r\n to be appended to the query StringBuilder

What about declared types?

The KotlinPoet code for the declared property is very similar to the one above. The function builder, on the other hand, has differences:

Generated Code

1. We generate a class field of the generated declared type; this is private because we are not interested in using it in our code outside of this class.
2. we generate an extension that will help us write this field as a DSL function in our parent DSL.
3. We initialize the class field with a new instance, and we pass the query to it, so everything is written inside this block is appended to the original query.
4. We apply the written block to the instance.

Now let’s generate it with KotlinPoet:

KotlinPoet Code
  • First, we specified a receiver for the extension by receiver(ClassName(info.queryPackage, info.queryClassName))
  • And then, we passed a lambda as a parameter to be the block
KotlinPoet Code

Next, we need to add a constructor to the query class:

The constructor is doing 2 things here,
If this query the parent query meaning it’s the first one created, then instantiate a StringBuilder query with GraphQL query convention {\”query\”:\”query” If this query class is not a parent query meaning the query object already created before this point -cascaded- so all the appending will be done on that query, and no need to create a new one.

Generated Code

KotlinPoet code for thos will be:

Kotlin Poet Code

String query builder function

Here we close the quires hierarchy; close the closest opened nested fields opened if this query is cascaded, close the first and second query sections.

Generated Code

KotlinPoet:

Kotlin Poet Code

Build the query class:

each section of the code inside the QueryBuilder class is warped inside an extension function for the TypeSpec.classBuilder KotlinPoet function

DSL Builder

Now the star of all of this is the DSL function.

You create a function with a lambda parameter type argument that is the block, the receiver is the current query class and no parameters and Unit return type.

Add one statement, applying the block to the query class and return it; this returned class is the query class that generates the query string and is used to add other parameters and fields in the app code.

Here is how it’s generated:

DSL builder

This will generate:

Generated Code

Remember how we use these functions inside the processor class?

First, build and add the class itself, then add the DSL function to the file; note that the function will be added outside of the class so it can be used anywhere without the need of creating an instance of that class.

from the QueryProcessor class

Usage

1. In any data class, annotate it with the annotation you created, note that every data class variable inside this data class also needs to be annotated

Annotation usage example

3. build your project, the generated files should be created

2. Then write a query anywhere in your app module code

Generated DSL usage example

3. Then get the string

query.buildQueryString() , this will generate the following string:

{\"query\":\"query {\\r\\n charactersQuery {\\r\\n results {\\r\\n id\\r\\n name\\r\\n  }\\r\\n  }\\r\\n }\\r\\n  \"}

Pass this as a RequestBody through retrofit and you are done!

10- How about some arguments?

Here well discuess adding filter to the query and it supports adding nested filters, but using the same steps any other type of arguments can be added.

If you are familiar with GraphQL queries, then you might be used to the variables object sent with the query that contains the filter values, here I kept away from using it and added the values directly inside the filter section after the filter name, not to complicate things further was the main reason for this.

Arguments usage example

The setArguments function accepts vararg of type Triple created by this function which lives inside the util module:

Custom to

Matchers is a sealed class that has some types you can use directly and Custom “infix types” like to, eq and match that lets you provide your own filter syntax to be passed to the filters section. See the util module in the code to find more.

  1. Start by generating a new StringBuilder for arguments
  2. Add arguments section starting with (
  3. Add a filter section, it typically starts with filter:{
  4. loop through the arg array, adding them one by one with this convention: name : value
  5. close the filter and arguments section
  6. Append them to the original query

The last 3 lines are the most interesting thing about this function IMO until before adding the filters, the query looks something like this …someName{\\r\\n but we want to write it in the format …someName(filters){\\r\\n so what I did is removing this section {\\r\\n adding the filters section then adding the {\\r\\n again!

Here is a simplified version of how the arguments are added:

Generated Code

KotlinPoet:

Kotlin Poet Code

When using this function like this

adding arguments

It’ll generate this query string

{"query":"query {\r\n characters  (filter:{ name : \"value\",id : 1 }) {\r\n results {\r\n id\r\n name\r\n  }\r\n  }\r\n }\r\n  "}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK