Kotlin DSL tutorial (simplified)

If you are reading this, I assume that you have reached the same point as I have in the past and you decided to dig a little bit and understand what Kotlin DSL is all about.

Upon googling it, I found the following post that explains this topic pretty well. If you wonder why this post exists, it’s because here I’ll try to explain the same topic with the same code example, but in a more detailed manner. I decided to do so because it took me a while to understand it myself and I’d like to simplify it for you.

The whole solution is available in the following file: Github

Starting point

fun main() {
    val sqlString = SqlBuilder().build()

    println("SQL: $sqlString")
}

class SqlBuilder {
    fun build() = "select * from my_table"
}

// Output: SQL: select * from my_table

Now let’s add top-level and higher-order function. A higher-order function is a function that takes functions as parameters or returns a function. Our function takes a function as a parameter and invokes it. After that, it returns a new instance of SqlBuilder.

More about Higher-Order Functions and Lambdas.

fun query(initializer: () -> Unit): SqlBuilder {
    initializer()
    return SqlBuilder()
}

Let’s use it:

fun main() {
	val sqlString = query {
    	println("Some actions")
    }.build()
    println("SQL: $sqlString")
}

// Output: 
// Some actions
// SQL: select * from my_table

Adding receiver type

Function types can optionally have an additional receiver type, which is specified before a dot in the notation: the type A.(B) -> C represents functions that can be called on a receiver object of A with a parameter of B and return a value of C

https://kotlinlang.org/docs/reference/lambdas.html#function-types

Now we will add a receiver type to our query function and try to figure out what happened.

// Before
fun query(initializer: () -> Unit): SqlBuilder {
    initializer()
    return SqlBuilder()
}

// After
fun query(initializer: SqlBuilder.() -> Unit): SqlBuilder {
    val sqlBuilder = SqlBuilder()
    return sqlBuilder
}

// Output: SQL: select * from my_table

The code of a function that we pass as a parameter doesn’t invoke. We should call the function to allow it to run. But now when we added a receiver type we need SqlBuilder instance to do it.

fun query(initializer: SqlBuilder.() -> Unit): SqlBuilder {
    val sqlBuilder = SqlBuilder()
    sqlBuilder.initializer()
    return sqlBuilder
}

// Output:
Some actions
SQL: select * from my_table

You would ask why we should do that? What it gives us? It gives us the ability to invoke SqlBuilder functions (line 3) from a function that we pass as a parameter. Let’s see how it looks all together:

fun main() {
    val sqlString = query {
    	anotherFunction()
    }.build()
    println("SQL: $sqlString")
}

fun query(initializer: SqlBuilder.() -> Unit): SqlBuilder {
    val sqlBuilder = SqlBuilder()
    sqlBuilder.initializer()
    return sqlBuilder
}

class SqlBuilder {
    fun build() = "select * from my_table"
    
    fun anotherFunction() {
        println("Another function")
    }
}

// Output:
Another function
SQL: select * from my_table

Let’s write a query function in a more concise way:

// Before
fun query(initializer: SqlBuilder.() -> Unit): SqlBuilder {
    val sqlBuilder = SqlBuilder()
    sqlBuilder.initializer()
    return sqlBuilder
}

// After
fun query(initializer: SqlBuilder.() -> Unit): SqlBuilder {
    return SqlBuilder().apply(initializer)
}

// Even shorter
fun query(initializer: SqlBuilder.() -> Unit) = SqlBuilder().apply(initializer)

Adding “select”

At line 10 we added a mutable list of strings and at lines 22-24 added a function that just adds columns names to that list.

fun main() {
    val sqlString = query {
        select("column1", "column2")
    }.build()

    println("SQL: $sqlString")
}

class SqlBuilder {
    private val columns = mutableListOf<String>()

    fun build(): String {
        val columnsToFetch =
            if (columns.isEmpty()) {
                "*"
            } else {
                columns.joinToString(", ")
            }
        return "select $columnsToFetch from my_table"
    }

    fun select(vararg columns: String) {
        this.columns.addAll(columns)
    }
}

fun query(initializer: SqlBuilder.() -> Unit) = SqlBuilder().apply(initializer)

Adding “from”

fun main() {
    val sqlString = query {
        select("column1", "column2")
        from("my_table")
    }.build()

    println("SQL: $sqlString")
}

class SqlBuilder {
    private val columns = mutableListOf<String>()
    private lateinit var table: String

    fun build(): String {
        val columnsToFetch =
            if (columns.isEmpty()) {
                "*"
            } else {
                columns.joinToString(", ")
            }
        return "select $columnsToFetch from $table"
    }

    fun select(vararg columns: String) {
        this.columns.addAll(columns)
    }
    
    fun from(table: String) {
        this.table = table
    }
}

fun query(initializer: SqlBuilder.() -> Unit) = SqlBuilder().apply(initializer)

Adding “where”

  • Adding a condition variable of type Condition that we will cover later (line 4).
  • At lines 13-19 we adding the conditions to the returned string.
  • At lines 22-24 we added a function ‘where‘ with another higher-order function with receiver type Condition
  • At line 23 we create an instance of And that is derived from Condition and because of that, we can apply initializer. In other words in the variable named condition, we save a reference for an object of type And and the code in initializer function is applied to it.
class SqlBuilder {
    private val columns = mutableListOf<String>()
    private lateinit var table: String
    private var condition: Condition? = null

    fun build(): String {
        val columnsToFetch =
            if (columns.isEmpty()) {
                "*"
            } else {
                columns.joinToString(", ")
            }
        val conditionString =
            if (condition == null) {
                ""
            } else {
                " where $condition"
            }
        return "select $columnsToFetch from $table$conditionString"
    }
//...
    fun where(initializer: Condition.() -> Unit) {
        condition = And().apply(initializer)
    }
}

Usage example. At lines 5-7 we are passing “column” eq 4.

eq” is infix function. Functions marked with the infix keyword be used omitting the dot and the parentheses. Read more about it here: https://kotlinlang.org/docs/reference/functions.html#infix-notation

fun main() {
    val sqlString = query {
        select("column1", "column2")
        from("my_table")
        where {
            "column" eq 4
        }
    }.build()

    println("SQL: $sqlString")
}

// Output:
// SQL: select column1, column2 from my_table where (column = 4)

First, we pass “column” eq 4 to the where function. There we create an instance of And. And derived from CompositeCondition(“and”) (line 33).

When “column” eq 4 evaluated (line 5) function addCondition called and Eq instance passed.

This instance is added to the mutable list in CompositeCondition line 21.

And after that when toString is called on Eq instance, “column” eq 4 converted to “column = 4”

abstract class Condition {
    protected abstract fun addCondition(condition: Condition)

    infix fun String.eq(value: Any?) {
        addCondition(Eq(this, value))
    }

    fun and(initializer: Condition.() -> Unit) {
        addCondition(And().apply(initializer))
    }

    fun or(initializer: Condition.() -> Unit) {
        addCondition(Or().apply(initializer))
    }
}

open class CompositeCondition(private val sqlOperator: String) : Condition() {
    private val conditions = mutableListOf<Condition>()

    override fun addCondition(condition: Condition) {
        conditions += condition
    }

    override fun toString(): String {
        return if (conditions.size == 1) {
            conditions.first().toString()
        } else {
            conditions.joinToString(prefix = "(", postfix = ")", separator = " $sqlOperator ")
        }
    }
}

class And : CompositeCondition("and")

class Or : CompositeCondition("or")

class Eq(private val column: String, private val value: Any?) : Condition() {

     override fun addCondition(condition: Condition) {}

    override fun toString(): String {
        return when (value) {
            null -> "$column is null"
            is String -> "$column = '$value'"
            else -> "$column = $value"
        }
    }
}

That’s it. I hope this article has actually simplified the topic and you are now more familiar with Kotlin DSL.


Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.