Skip to Content
Course content

246: Practice Exercise: Building a Type-Safe SQL Query Builder

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When I first started writing DSLs (Domain Specific Languages) in Kotlin, I thought the goal was simply to make the code look like the target language. I focused on the aesthetics of the syntax, but I completely ignored the "type-safe" part of the equation. I ended up building a query builder that looked beautiful but allowed developers to write completely invalid SQL that only crashed at runtime.

Take a look at this snippet. It looks clean, right? But there's a ticking time bomb in here:

val query = sqlQuery {
    select("username", "email")
    from("users")
    where("status = 'active'")
    where("age > 18") // Wait...
}
// Result: "SELECT username, email FROM users WHERE status = 'active' WHERE age > 18"

The Double-Where Disaster

The bug here is structural. In my naive implementation, the where function simply appended a string to a list of clauses. Because where was a member of the main QueryBuilder class, I could call it as many times as I wanted. SQL, of course, only allows a single WHERE keyword per select statement. Any subsequent conditions must be joined by AND or OR.

If you're building a tool for other developers, you can't rely on them reading a README that says "Please only call where once." You need to make it impossible to write that bug. In Kotlin, we do this by changing the receiver context.

Enforcing Structure with Context Shifting

The fix is to stop where from being a simple "append" function and instead make it a gateway to a new, restricted context. Instead of the QueryBuilder handling the conditions, we create a WhereContext class that specifically handles and and or logic.

Here is how I refactored the implementation to ensure the WHERE keyword is only ever emitted once:

class QueryBuilder {
    private var selectClause = ""
    private var fromClause = ""
    private var whereClause = ""

    fun select(vararg columns: String) {
        selectClause = "SELECT ${columns.joinToString(", ")}"
    }

    fun from(table: String) {
        fromClause = "FROM $table"
    }

    // Notice we change the context here
    fun where(init: WhereContext.() -> Unit) {
        val context = WhereContext()
        context.init()
        whereClause = "WHERE ${context.build()}"
    }

    fun build(): String = "$selectClause $fromClause $whereClause".trim()
}

class WhereContext {
    private val conditions = mutableListOf<String>()

    fun condition(sql: String) {
        conditions.add(sql)
    }

    fun and(sql: String) {
        conditions.add(sql) // We'll join these with AND later
    }

    fun build(): String = conditions.joinToString(" AND ")
}

Now, look at how the usage changes. The compiler now forces the developer into the WhereContext block:

val query = sqlQuery {
    select("username", "email")
    from("users")
    where {
        condition("status = 'active'")
        and("age > 18")
    }
}
// Result: "SELECT username, email FROM users WHERE status = 'active' AND age > 18"

The "Double-Where" bug is gone. Because the where function is no longer inside the WhereContext, you can't call where { where { ... } }. You've used the type system to create a state machine that mirrors the actual requirements of the SQL language.

Tightening the Screws with DslMarker

There's still one sneaky problem. If WhereContext were a nested class inside QueryBuilder, Kotlin would allow the inner lambda to call functions from the outer class. You could potentially call select() from inside your where block, which would be logically nonsensical.

To stop this, I always use @DslMarker. By creating a custom annotation like @SqlDsl and marking both builder classes with it, Kotlin will throw a compile-time error if you try to access a receiver from an outer scope inside an inner scope. It keeps the DSL "flat" and prevents the user from jumping back and forth between different parts of the query construction.




📋 Practical Task

Exercise: Implementing a Type-Safe Join Builder

Now it's your turn. You need to extend the query builder to support JOIN operations. A JOIN requires a table name and an ON condition. To make this type-safe, you must ensure that the on clause can only be called immediately after a join call.

Requirements:

  • Create a join function in QueryBuilder that takes a table name and a lambda.
  • Create a JoinContext class to handle the on condition.
  • Ensure that the on function is only accessible within the join block.
  • The final output should format as: SELECT ... FROM table JOIN other_table ON condition.

Starter Code:

// Implement the missing pieces
class QueryBuilder {
    private var selectClause = ""
    private var fromClause = ""
    private var joinClauses = mutableListOf<String>()

    fun select(vararg columns: String) { selectClause = "SELECT ${columns.joinToString(", ")}" }
    fun from(table: String) { fromClause = "FROM $table" }

    // TODO: Implement join() here
    
    fun build(): String {
        val joins = joinClauses.joinToString(" ")
        return "$selectClause $fromClause $joins".trim()
    }
}

// TODO: Implement JoinContext here

fun sqlQuery(init: QueryBuilder.() -> Unit): String {
    val builder = QueryBuilder()
    builder.init()
    return builder.build()
}

// Test case:
// val result = sqlQuery {
//    select("u.name", "o.order_date")
//    from("users u")
//    join("orders o") {
//        on("u.id = o.user_id")
//    }
// }
// Expected: "SELECT u.name, o.order_date FROM users u JOIN orders o ON u.id = o.user_id"
Rating
0 0

There are no comments for now.

to be the first to leave a comment.