Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to enforce usage rules on Custom Kotlin DSL

Tags:

kotlin

dsl

Im investigating Kotlin DSLs following these examples:-

https://github.com/zsmb13/VillageDSL

Im am interested in how to enforce usage rules on all attributes exposed by the DSL.

Taking the following example:-

val v = village {
    house {
        person {
            name = "Emily"
            age = 31
        }
         person {
            name = "Jane"
            age = 19
        }
    }
}

I would like to enforce a rule which stops users of the DSL being able to enter duplicate attributes as shown below

val v = village {
    house {
        person {
            name = "Emily"
            name = "Elizabeth"
            age = 31
        }
         person {
            name = "Jane"
            age = 19
            age = 56
        }
    }
}

I've tried with Kotlin contracts e.g.

contract { callsInPlace(block, EXACTLY_ONCE) }

However these are only allowed in top level functions and I could not see how to employ a contract when using following the Builder pattern in DSLs, e.g.

@SimpleDsl1
class PersonBuilder(initialName: String, initialAge: Int) {
    var name: String = initialName
    var age: Int = initialAge

    fun build(): Person {
        return Person(name, age)
    }
}

Is it possible to achieve my desired effect of enforcing the setting of each attribute only one per person?

like image 236
Hector Avatar asked May 23 '20 10:05

Hector


2 Answers

Unfortonate that you cannot use contracts to get the compilation error you are looking for. I do not think they are intended for the purpose you are tying here... but I might be wrong. To me they are hints to the compiler about things like nullability and immutability. Even if you were able to use them as you wished, I do not think you would get the compilation error you are looking for.

But a second place solution would be to have an Exception at runtime. Property delegates could provide you with a nice reusable solution for this. Here it is with some modification to your example.

class PersonBuilder {
    var name: String? by OnlyOnce(null)
    var age: Int? by OnlyOnce(null)

    fun build(): Person {
        name?.let { name ->
            age?.let { age ->
                return Person(name, age)
            }
        }
        throw Exception("Values not set")
    }
}

class OnlyOnce<V>(initialValue: V) {

    private var internalValue: V = initialValue
    private var set: Boolean = false

    operator fun getValue(thisRef: Any?, property: KProperty<*>): V {
        return internalValue
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: V) {
        if (set) {
            throw Exception("Value set already")
        }
        this.internalValue = value
        this.set = true
    }
}

fun person(body: PersonBuilder.() -> Unit) {
    //do what you want with result
    val builder = PersonBuilder()
    builder.body()
}

fun main() {
    person {
        name = "Emily"
        age = 21
        age = 21 // Exception thrown here
    }
}
like image 74
Laurence Avatar answered Oct 22 '22 11:10

Laurence


I found a hacky way to do something similar, but then it turned out infix functions won't work here because of this bug. When it does get fixed, this solution should be okay.

You could make your DSL look like this, but unfortunately, your first set call can't be infix :( because then name cannot be smartcasted to SetProperty<*> (see the bug report above).

val emily = person {
        name.set("Emily")
        name.set("Elizabeth") //Error here
        age.set(31)
        age set 90 //Won't work either
    }

The error that pops up (for name.set("Elizabeth")) is:

Type inference failed: Cannot infer type parameter T in inline infix fun <reified T> Property<T>.set(t: T): Unit
None of the following substitutions
receiver: Property<CapturedTypeConstructor(out Any?)>  arguments: (CapturedTypeConstructor(out Any?))
receiver: Property<String>  arguments: (String)
can be applied to
receiver: UnsetProperty<String>  arguments: (String)

The code behind it:

@OptIn(ExperimentalContracts::class)
infix fun <T> Property<T>.set(t: T){
    contract { returns() implies (this@set is Prop<*>) }
    this.setData(t)
}

interface Property<T> {
    fun data(): T
    fun setData(t: T)
}

interface UnsetProperty<T> : Property<T>

open class SetProperty<T>(val name: String) : Property<T> {
    private var _data: T? = null
    override fun data(): T { return _data ?: throw Error("$name not defined") }
    override fun setData(t: T) {
        if (_data == null) _data = t
        else throw Error("$name already defined")
    }
}

class Prop<T>(name: String = "<unnamed property>") : SetProperty<T>(name), UnsetProperty<T>

class PersonBuilder {
    val name: Property<String> = Prop("name")
    val age: Property<Int> = Prop("age")
    fun build(): Person = Person(name.data(), age.data())
}

fun person(f: PersonBuilder.() -> Unit): Person {
    val builder = PersonBuilder()
    builder.f()
    return builder.build()
}

data class Person(val name: String, val age: Int)

I'm not sure exactly why this works/doesn't work, but it seems that because T is invariant in Property, it can't determine what exactly it is.


However, it would be much easier and safer to just use named arguments for your person function and make house, village, etc. have variadic parameters.

like image 2
user Avatar answered Oct 22 '22 10:10

user