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?
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
}
}
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With