Skip to content
Juan Saravia edited this page Jun 11, 2020 · 14 revisions

Introduction

AutoDsl is an annotation processing library to generate DSL (Domain Specific Language) from your Kotlin classes.

DSL is a great way to provide abstraction from a particular application domain so your users/clients can easily interact with your systems/libraries/apis in a more readable way.

Table of Contents

  1. Annotations

    1. @AutoDsl

    2. @AutoDslConstructor

    3. @AutoDslCollection

  2. Default Parameters

Annotations

The library comes with a set of annotations that will tell the processor how to generate your DSL. The annotations covers most of the common cases and for more advance one we will see later how to achieve them.

@AutoDsl

The main annotation is @AutoDsl which instructs the processor to produce your DSL.

Let's review a simple scenario that will guide us to understand more complex scenarios:

@AutoDsl
class Person(val name: String)

This is going to produce a DSL for Person, so now you can create a new instance of Person class with this syntax:

val me = person {
    // init block
    name = "Carlos Gardel"
}

Behind the scene the AutoDsl is producing a Function and a Builder. The Builder PersonAutoDslBuilder is the mutable object where you will be able to initialise all your variables, so Person can remains as a non-mutable object and the function person to create the Person instance using the builder.

The auto-generated code will look like this:

fun person(block: PersonAutoDslBuilder.() -> Unit): Person = PersonAutoDslBuilder().apply(block).build()

@AutoDslMarker
class PersonAutoDslBuilder() {
    var name: String by Delegates.notNull()
    fun withName(name: String): PersonAutoDslBuilder = this.apply { this.name = name }
    fun build(): Person = Person(name)
}

Custom DSL name

The function name will be defined by the Class name de-capitalized. You can configure that name by simple doing:

@AutoDsl(dslName = "newPerson")
class Person(val name: String)

and invoke it like this:

val me = newPerson {

Required vs Optional parameters

By default all properties are treated as Required so in this case if you don't provide a name for person then at runtime you will receive an exception indicating that this field name is required and must be set. In order to indicate is Optional you just need to set the property as nullable type with the ?. So if I update Person class like this it will not throw an exception and the variable will be initialized with a null value:

class Person(val name: String?)

DslMarker

By default AutoDsl will use an internal DslMarker called @AutoDslMarker so you don't have to worry about scopes inside builders. You can read more about the purpose of it in this DslMarker Kotlin page.

Properties annotated with @AutoDsl

Let's update our Person class with more properties:

@AutoDsl
class Person(
    val name: String,
    val age: Int,
    val address: Address?
)

@AutoDsl
class Address(val street: String, val zipCode: Int)

Now one of the properties type in Person is also annotated with @AutoDsl, which is Address.

If the property were not recognized then you would have something like this:

val me = person {
            name = "Carlos Gardel"
            age = 60
            address = address {
                street = "200 Celebration Bv"
                zipCode = 34747
            }

But the library will provide a better integration with Address allowing you to do this:

val me = person {
            name = "Carlos Gardel"
            age = 60
            address {
                street = "200 Celebration Bv"
                zipCode = 34747
            }

Java Friendly

We didn't forget about Java, so the Builders will be usable from Java and also with handy methods to create new instances like this:

new PersonAutoDslBuilder()
                .withName("Juan")
                .withAge(36)
                .withAddress(new AddressAutoDslBuilder()
                        .withStreet("200 Celebration Bv")
                        .withZipCode(34747)
                        .build())
                .build();

@AutoDslConstructor

When there is more than one constructor this is the logic to choose one:

  1. Use the annotated constructor with @AutoDslConstructor.
  2. If no annotation, then take the first public constructor.
  3. If no public constructor nor annotation, then show an error that the DSL cannot be generated.

This is a valid example:

@AutoDsl
class Location {
    val lat: Float
    val lng: Float

    constructor() {
        lat = 0F
        lng = 0F
    }

    @AutoDslConstructor
    constructor(lat: Float, lng: Float) {
        this.lat = lat
        this.lng = lng
    }
}

@AutoDslCollection

Collections are one of the most interesting parts in DSL. Let's take this example:

@AutoDsl
class Person(
    val name: String,
    ...
    val friends: List<Person>?,
    val keys: Set<String>?
)

Normally you would have to define the concrete type and the list in this way:

     person {
            ...
            friends = listOf(
                person {
                    name = "Arturo"
                    age = 30
                },
                person {
                    name = "Tiwa"
                    age = 31
                }
            )
     }

But with AutoDsl you will have a better integration for Collections. So for the previous example you can improve it like this without changing anything in your models:

      person {
            ...
            friends {
                +person {
                    name = "Arturo"
                    age = 30
                }
                +person {
                    name = "Tiwa"
                    age = 31
                }
            }
      }

Much better right!

Currently the out-of-the-box supported collections are:

Collection Type Concrete Type
List ArrayList
MutableList ArrayList
Set HashSet
MutableSet HashSet

Custom collection types

If you want to specify the type of the collection, let's say keys has to be a TreeSet, then this is what you can do:

@AutoDsl // indicates to create an associated Builder for this class
class Person(
    ...
    @AutoDslCollection(concreteType = TreeSet::class)
    val keys: Set<String>?
)

Inline collections

Another property inside @AutoDslCollection is inline flag. This flag will be really useful to avoid long nested lists. Let's take this example:

@AutoDsl
internal class Box(
    val items: Set<String>,
    val stamps: List<Stamp>?
)

@AutoDsl
internal class Stamp(
    val names: List<String>
)

This will generate the following DSL:

      box {
            items {
                +"Hello"
                +"World"
            }
            stamps {
                +stamp {
                    names {
                        +"USA"
                        +"ARG"
                    }
                }
            }
        }

As you can see we have stamps that contains a stamp and of this one a list of names. To avoid this long nested list, we have another feature called inline inside @AutoDslCollection and for this example let's avoid declaring stamps function and just add a stamp directly in the context of the box that which at the end will be added into the stamps variable.

So in order to do that you just need to add the @AutoDslCollection annotation with inline in true:

@AutoDsl
internal class Box(
    val items: Set<String>,
    @AutoDslCollection(concreteType = ArrayList::class, inline = true)
    val stamps: List<Stamp>?
)

And now you will be able to use the DSL like this:

        box {
            +stamp {
                names {
                    +"USA"
                    +"ARG"
                }
            }
        }

This is much cleaner! The DSL will take care of the newly added stamp and put it into the stamps variable.

Important Note: If you have two or more collections with the same parameterized type, let's say stamps is a List<String> and items a Set<String>, you will not be able to use @AutoDslCollection with inline in true for both collections as it's not possible to know if the String that you are trying to add would go to the list or the set. But if the parameterized types are different then you are free to use it in both collections.

Default Parameters

Default parameters is not currently supported as there is no way to get those default values from Kotlin Metadata in the annotation process as at the end those values are assigned inside the constructor.

One way to mitigate this would be to do this:

class Person(val name: String, 
             friends: List<Person>?) {
    val friends = friends ?: emptyList()
}

Remove the val from friends constructor and defined it internally with a null check.

I now this doesn't feel good but it's the cleaner option that I have come up right now.

Sealed Classes

If you want to annotate a class inside a Sealed Class then you will need to declare the sealed classes outside the main sealed class like this:

sealed class StampType

@AutoDsl
class GoldStamp(val price: Double): StampType()
object MetalStamp : StampType()
object BronzeStamp : StampType()

This is the only way to auto-generate the builder and extension function of this annotated class with AutoDsl.