-
Notifications
You must be signed in to change notification settings - Fork 15
Home
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.
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.
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)
}
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 {
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?)
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.
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
}
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();
When there is more than one constructor this is the logic to choose one:
- Use the annotated constructor with
@AutoDslConstructor
. - If no annotation, then take the first public constructor.
- 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
}
}
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 |
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>?
)
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 aSet<String>
, you will not be able to use@AutoDslCollection
withinline
intrue
for both collections as it's not possible to know if theString
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 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.
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.