Hector
Hector

Reputation: 5356

How to enforce usage rules on Custom 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?

Upvotes: 11

Views: 1017

Answers (3)

Benjamin Charais
Benjamin Charais

Reputation: 1368

While writing this, I noticed that the Bug I referenced in my comments has been fixed, so I continued down that code path only to realize, there is a language limitation, at the bottom I will include an example of what I meant in the comments.

Both of these examples would be failures at runtime.

    fun test_person() {
    val village = village {
        house {
            person {
                name = "Emily"
    //            ::name setTo "Emily" // Commented for 2nd example of Person
                age = 31
            }
            person {
                name = "Jane"
                age = 19
            }
        }
        house {
            person {
                name = "Tim"
     //           name = "Tom" // Will break with exception
                age = 20
            }
        }
    }

    println("What is our village: \n$village")
}

The runtime breaking example that works with exceptions:

class Village {
    val houses = mutableListOf<House>()

    fun house(people: House.() -> Unit) {
        val house = House()
        house.people()
        houses.add(house)
    }

    override fun toString(): String {
        val strB = StringBuilder("Village:\n")
        houses.forEach { strB.append("  $it \n") }
        return strB.toString()
    }
}

fun village(houses: Village.() -> Unit): Village  {
    val village = Village()
    village.houses()
    return village
}

class House {
    val people = mutableListOf<Person>()

    fun person(qualities: Person.() -> Unit) {
        val person = Person()
        person.qualities()
        people.add(person)
    }

    override fun toString(): String {
        val strB = StringBuilder("House:\n")
        people.forEach{ strB.append("    $it \n")}
        return strB.toString()
    }
}

class Person {
    var age by SetOnce<Int>()
    var name by SetOnce<String>()

    override fun toString(): String {
        return "Person: { Name: $name, Age: $age }"
    }
}

class SetOnce <T> : ReadWriteProperty<Any?, T?> {
    private var default: T? = null

    override fun getValue(thisRef: Any?, property: KProperty<*>): T? = default

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) {
        if (default != null) throw Exception("Duplicate set for ${property.name} on $thisRef")
        else default = value
    }
}

The Non-working example that was intended to use lateinit properties to control the setting of a value once, but you cannot use a reference, it must be the literal ::foo syntax. Like I said, I didn't realize that bug was fixed, and did not know this was a limitation in the language

class Person {
    lateinit var name: String
    lateinit var age: Number // Number because primitives can't be lateinit

    /** @throws Exception when setting a property a second time */
    infix fun <T> KMutableProperty0<T>.setTo(value: T) {
        val prop = getProp<T>(this.name)

        if (prop.isInitialized.not()) this.set(value)
        else throw Exception("Duplicate set for ${this.name}")
    }

    private fun <T> getProp(name: String): KMutableProperty0<T> {

        return when(name) {
            "name" -> ::name
            "age" -> ::age
            else -> throw Exception("Non-existent property: $name")
        } as KMutableProperty0<T>
    }
}

As contracts mature and the rules are relaxed, we could potentially write something like:

@OptIn(ExperimentalContracts::class)
infix fun <T> KMutableProperty0<T>.setTo(value: T) {
    contract { returns() implies [email protected] }
    this.set(value)
}

Which would give us the capability to move this all to IDE errors, but we're not there yet sadly.

Upvotes: 2

user
user

Reputation: 7604

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.

Upvotes: 2

Laurence
Laurence

Reputation: 1676

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
    }
}

Upvotes: 7

Related Questions