How do I encode an enclosing class on a field so I can make a type-safe DSL in Kotlin?

I have a collection of POJO’s that are linked together in such a way:

A {
    value1
    value2
    B {
        value3
        ...
    }
}

etc, done a million times before.

My task is to create the following: an external party comes in with an XML definition of which entities/values they want to receive, a la GraphQL / ProtoBuf. In addition to this, we need to create “standard” messages with predefined schemas, so not only do the external party give XML, but our programmers also need to define some of those messages.
I want to do this last part type safe, so my first thought was a Kotlin DSL. I’ve got most of it working, it’s kind of looking like this:

aScope = scope(AModel) {
    addField(AModel.value1)
    addEntity(BModel) {
        addField(BModel.value3)
    }
}

object AModel {
    val value1 = field<String>()
    val value2 = field<Int>()
    val b = relationship(to = BModel)
}
object BModel {
    val value3 = field<LocalDate>()
}

This all works fine already, but I want it to be type-safe. For example, right now this is legal:

aScope = scope(AModel) {
    addField(BModel.value3)
}

To add to the complexity, some entities have common fields, so like a good OO monkey, I gave the Model objects abstract superclasses with more fields in them, which is nice, because you can do AModel.commonField and BModel.commonField and they’re different unique fields :smiley:

I think to accomplish this, I have to encode the enclosing Model class on a field somehow. Then I could do something like

fun Scope<ModelClass>.addField(field: Field<ModelClass>) { ... }

but I can’t figure out how to do this without being extremely verbose like

object AModel {
    val value1 = field<AModel, String>()
}

and even then I can’t get the common fields to work, because I can’t seem to pass the actual Model class upwards to the abstract superclass.

Any advice is welcome, and sorry for the book! Your help is much appreciated!

I like to give step by step solutions.
If you’re just interested in the end-result, I collapsed the steps.

step by step

initial given

Want to link to a class at compiletime? Use a generic.
Therefor, my field looks like:

class Field<MODEL, TYPE>

basic implementation

The most basic implementation requires the class to add it’s type to the function

fun <MODEL, TYPE> field() = Field<MODEL, TYPE>()

object AModel {
    val value1 = field<AModel, String>()
    val value2 = field<AModel, Int>()
}

object BModel{
    val value3 = field<BModel, String>()
}

now you can create a dsl-class which accepts fields where the first generic is of a specific type:

class ScopeDsl<M>{
    fun addField(field : Field<M, *>) : Unit = TODO()
}
//and function
fun <MODEL> scope(
        scope : MODEL,
        script : ScopeDsl<MODEL>.()->Unit
) = Unit

we can use it and see it does what it should:

scope(AModel){
    addField(AModel.value1) // allowed
    addField(BModel.value2) // not allowed
}

helper functions

We can let the function create the field itself:

object AModel {
    fun <T> field() = Field<Amodel, T>()
    val value1 = field<String>()
}

This needs to be added to every method and so contains loads of duplication.

move helper-function to interface

We can move the function to an interface.
This interface needs to know where to add the function to.
Therefor, the interface needs a generic.

interface Model<M>{
    fun <T> field() = Field<M, T>()
}

object AModel : Model<AModel>{
    val value1 = field<String>()
}

common props

Don’t know enough, but I think it already works.
You just need to pass the generic of the class.

abstract class Common<M> : Model<M>{
    val common = field<String>()
}

object AModel : Common<AModel>(){
    val value1 = field<String>()
}
val aScope = scope(AModel) {
    addField(AModel.value1)
    addField(AModel.common)
}

do note, the field-method is not final and overwriting it will lead to problems.
You have two options:

  • ignore the warnings and the issue
  • make the interface an abstract class where the method can be final.
    This will also mean that no other classes can be extended (models are not allowed anyways)

relationship

The relationShip uses the same principles:

class Relation<FROM, TO>

interface Model<M>{ 
    fun <T> relationShop(to : TO) = Relation<M, TO>()
}
class ScopeDsl<M>{
    fun <TO> addEntity(
        relation : Relation<M, TO>,
        script : ScopeDSL<TO>.() -> Unit
   ) : Unit = TODO()
}

DSLmarker

val aScope = scope(AModel) {
    val a = this
    addEntity(AModel.relation) {
        val b = this
        addField(AModel.value1) // compiles
    }
}

The code above does compile to:

a.addField(Amodel.value1)

instread of

 b.addField(AModel.value1)

This is because b cannot accept fields from AModel.
To stop it from compiling use DSLMarker:

@DSLMarker
 annotation class ScopeDSLMarker

 @ScopeDSLMarker
  class ScopeDSL<M>{...}

Now the code above doesn’t compile anymore.

all together

@DSLMarker
 annotation class ScopeDSLMarker


class Field<M, Type>
class Relation<FROM , TO>
interface Model<M>{
    fun <Type> field() = Field<M, Type>()
    fun <TO> relationship(to: TO) = Relation<M, TO>()
}

@ScopeDSLMarker
class ScopeDSL<M>{
    fun <TO> addEntity(
            relation : Relation<M, TO>,
            script : ScopeDSL<TO>.() -> Unit
    ) : Unit = TODO()
    fun addField(field : Field<M, *>) : Unit = TODO()
}

fun <M> scope(
        model : M,
        script : ScopeDSL<M>.()->Unit
) = Unit
val aScope = scope(AModel) {
    addField(AModel.value1)
    addField(AModel.common)
    addEntity(AModel.relation) {
        addField(BModel.value3)
    }
}

abstract class Common<M> : Model<M>{
    val common = field<String>()
}
object AModel : Common<AModel>(){
    val value1 = field<String>()
    val value2 = field<Int>()
    val relation = relationship(to = BModel)
}
object BModel : Model<BModel>{
    val value3 = field<String>()
}

val aScope = scope(AModel) {
    addField(AModel.value1)
    addField(AModel.common)
    addEntity(AModel.relation) {
        addField(BModel.value3)
    }
}

Bonus - restrict generic

The generic passed to Model can be restricted to a generic that extends Model.
interface Model<M : Model>
The same counts for all the common-classes.
(This is the same way as generics works)
The bonus-restriction makes the code look way more ugly, so you should see look if you want it

class Field<M : Model<M>, Type>
class Relation<FROM : Model<FROM>, TO : Model<TO>>
interface Model<M : Model<M>>{
    fun <Type> field() = Field<M, Type>()
    fun <TO : Model<TO>> relationship(to: TO) = Relation<M, TO>()
}
@DSLMarker
 annotation class ScopeDSLMarker

@ScopeDSLMarker
class ScopeDSL<M : Model<M>>{
    fun <TO : Model<TO>> addEntity(
            relation : Relation<M, TO>,
            script : ScopeDSL<TO>.() -> Unit
    ) : Unit = TODO()
    fun addField(field : Field<M, *>) : Unit = TODO()
}

fun <M : Model<M>> scope(
        model : M,
        script : ScopeDSL<M>.()->Unit
) = Unit

abstract class Common<M : Common<M>> : Model<M>{
    val common = field<String>()
}
object AModel : Common<AModel>(){
    val value1 = field<String>()
    val value2 = field<Int>()
    val relation = relationship(to = BModel)
}
object BModel : Model<BModel>{
    val value3 = field<String>()
}
3 Likes