-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Null Safety
-
Section 4: Object-Oriented Kotlin
-
Section 5: Functional Kotlin
-
Section 6: Coroutines
-
Section 7: Collections Deep Dive
-
Section 8: Type System Deep Dive
-
Section 9: Interop and Tooling
-
Section 10: Kotlin DSLs and Patterns
-
Section 11: Testing and Quality
-
Section 12: Server-Side Kotlin
-
Section 13: Practical Projects
-
Section 14: Interview Practice
-
Section 15: More Practice Exercises
-
Section 16: More Standard Library
-
Section 17: Multiplatform Kotlin
-
Section 18: kotlin.collections In Depth
-
Section 19: kotlin.text In Depth
-
Section 20: kotlin.ranges and kotlin.sequences
-
Section 21: kotlin.io and File Handling
-
Section 22: kotlinx.coroutines Deep Dive
-
Section 23: kotlin.reflect
-
Section 24: Android Development with Kotlin Overview
-
Section 25: Kotlin Multiplatform Deep Dive
-
Section 26: Kotlin for Backend Deep Dive
-
Section 27: Kotlin Design Patterns
-
Section 28: Advanced Language Features
-
Section 29: More Practice Exercises
-
Section 30: More Interview Practice
-
Section 31: Kotlin Type System Deep Dive
-
Section 32: Kotlin Null Safety Advanced
-
Section 33: Kotlin Testing Deep Dive
-
Section 34: Kotlin Build Tooling Deep Dive
-
Section 35: Kotlin Serialization
-
Section 36: Kotlin Performance Considerations
-
Section 37: Kotlin Native Overview
-
Section 38: Kotlin for Data and Scripting
-
Section 39: More Coroutines Practice
-
Section 40: More Android-Adjacent Patterns
-
Section 41: More Practical Projects
-
Section 42: More Design and Architecture Practice
-
Section 43: Kotlin Language Evolution
-
Section 44: More Interview and Review
-
Section 45: Kotlin Delegation Patterns Deep Dive
-
Section 46: Kotlin Annotations Deep Dive
-
Section 47: Kotlin for Gradle Plugin Development
-
Section 48: Kotlin Concurrency Beyond Coroutines
-
Section 49: Kotlin Compiler Internals
-
Section 50: Real-World Kotlin Case Studies
-
Section 51: Final Practice Projects
-
Section 52: Kotlin for Server-Side Reactive Programming
-
Section 53: More Practice and Drills
-
Section 54: Kotlin Security Practices
246: Practice Exercise: Building a Type-Safe SQL Query Builder
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
joinfunction inQueryBuilderthat takes a table name and a lambda. - Create a
JoinContextclass to handle theoncondition. - Ensure that the
onfunction is only accessible within thejoinblock. - 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"
There are no comments for now.