11

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?

Hector
  • 4,016
  • 21
  • 112
  • 211
  • 2
    This might help: https://stackoverflow.com/questions/58150954/how-to-force-client-code-to-initialize-all-required-builder-fields-in-kotlin-wit – user Jun 03 '20 at 18:49
  • @user thats nice work, however it doesnt stop me calling age(21) multiple times – Hector Jun 04 '20 at 11:18

3 Answers3

7

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
    }
}
Laurence
  • 1,556
  • 10
  • 13
2

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.

user
  • 7,435
  • 3
  • 14
  • 44
  • This is very similar to the approach I was going to suggest, using property references `::foo` but a bug prevents that as well: https://youtrack.jetbrains.com/issue/KT-17018 – Benjamin Charais Jun 02 '20 at 21:51
  • @BenjaminCharais I'd love to see your approach, since I'm not sure exactly how that bug relates to this. Could you post an answer? – user Jun 02 '20 at 22:03
  • 1
    The biggest difference is rather than having everything go through a SetProperty, the pattern would allow for dynamic interpretation of the properties, so modifying the class itself would be relatively painless and look very similar to normal code implementation, everything else would be the same more or less. I can put up a stub example later. -- Edit -- I'll write it to work with exceptions so that it's a viable answer, and potentially helpful – Benjamin Charais Jun 02 '20 at 22:46
2

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 this@setTo.isInitialized }
    this.set(value)
}

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

Benjamin Charais
  • 1,248
  • 8
  • 17