- Published on
Building a Query Expression Language in Kotlin, plus dynamic SQL Generation using jOOQ
- Authors
- Name
- Gavin Ray
- @GavinRayDev
Table of Contents
Overview
In this post we'll walk through building a simple query expression language in Kotlin, and then using it to generate dynamic SQL. First via a simple string-producing function, then through a less fragile method via jOOQ's query DSL.
Motivation
This sort of AST/intermediate representation of data-compute operations is useful in many situations, including:
- Creating structured/serializeable expressions that can be transfered between systems
- Writing translations between query languages
- The development of query engines and their planners/optimizers
In my spare time over the past months I've had a passion project that involves parsing GraphQL queries into an IR, and then generating + executing SQL from it.
This post is an attempt to share some of the knowledge and tips I've gained from working on that project, as well as showcase what an excellent language Kotlin is.
Goals
What we'll ultimately be building is a program that can consume/produce a structured operation expression like the below:
{
"select": ["id", "name", "age"],
"from": "users",
"where": {
"type": "EQ",
"left": {
"type": "COLUMN",
"name": "name"
},
"right": {
"type": "LITERAL",
"value": "John"
}
},
"groupBy": null,
"orderBy": ["name"],
"limit": 5,
"offset": 2
}
And convert it into the corresponding SQL query:
select id, name, age
from users
where name = 'John'
order by name
offset 2 rows
fetch next 5 rows only
Represented as code, this expression will look like this:
Query(
from = "users",
select = listOf("id", "name", "age"),
orderBy = listOf("name"),
limit = 5,
offset = 2,
where = EQ(
COLUMN("name"),
LITERAL("John")
)
)
Architecture
The high-level architecture of the program is as follows:
- A sealed interface/algebraic data type,
Expression
, which represents the abstract syntax tree of a querywhere
clause - An interface,
ExpressionTranslator<T>
which represents a function that translates anExpression
into a concrete value- A class,
SimpleStringExpressionTranslator
, which implementsExpressionTranslator<String>
- A class,
jOOQExpressionTranslator
, which implementsExpressionTranslator<org.jooq.QueryPart>
- A class,
- A class,
Query
, which contains the entirety of single query operation:select
/from
/where
/group by
/order by
/limit
/offset
- An interface,
QueryTranslator
which represents a function that translates aQuery
into a concrete value- A class,
SimpleStringQueryTranslator
, which implementsQueryTranslator<String>
- A class,
jOOQQueryTranslator
, which implementsQueryTranslator<String>
- A class,
Let's Build It
Expression
AST
Implementing the The below diagram shows the structure of the Expression
interface we'll be building (with some BinaryOperation
members omitted for size):
We have three classes of nodes:
BinaryOperation
's. These are operations that have two operands, such asEQ
,GT
,LT
, etc, as well as the booleanAND
andOR
UnaryOperation
's. These are operations that have one operand, such asNOT
,IS NULL
, etc- "Other" --
COLUMN
andLITERAL
, our two concrete/leaf nodes
Represented as Kotlin types, these classes look like this:
sealed interface Expression
sealed interface BinaryOperation : Expression {
val operator: String
val left: Expression
val right: Expression
}
sealed interface UnaryOperation : Expression {
val operator: String
val operand: Expression
}
data class AND(override val left: Expression, override val right: Expression) : BinaryOperation {
override val operator = "AND"
}
data class OR(override val left: Expression, override val right: Expression) : BinaryOperation {
override val operator = "OR"
}
data class EQ(override val left: Expression, override val right: Expression) : BinaryOperation {
override val operator = "="
}
// Let's skip the rest of the `BinaryOperation`s for brevity
data class NOT(override val operand: Expression) : UnaryOperation {
override val operator = "NOT"
}
data class IS_NULL(override val operand: Expression) : UnaryOperation {
override val operator = "IS NULL"
}
data class COLUMN(val name: String) : Expression
data class LITERAL(val value: Any) : Expression
Query
object
The Below will be our representation of a query:
data class Query(
val select: List<String>,
val from: String,
val where: Expression? = null,
val groupBy: List<String>? = null,
val orderBy: List<String>? = null,
val limit: Int? = null,
val offset: Int? = null
)
ExpressionTranslator
Interface
The We need an interface/typeclass that allows us to translate an Expression
into a concrete value.
This is the purpose of ExpressionTranslator
:
interface ExpressionTranslator<T> {
fun translate(expression: Expression): T
}
A simple String translator
Now we can implement the equivalent of an "interpreter" that produces a string from an Expression
AST.
We'll call this object SimpleStringExpressionTranslator
, and it will implement the ExpressionTranslator<String>
interface:
interface ExpressionTranslator<T> {
fun translate(expression: Expression): T
}
object SimpleStringExpressionTranslator : ExpressionTranslator<String> {
override fun translate(expression: Expression): String {
return when (expression) {
is COLUMN -> expression.name
is LITERAL -> expression.value.toString()
is BinaryOperation -> {
val left = translate(expression.left)
val right = translate(expression.right)
"$left ${expression.operator} $right"
}
is UnaryOperation -> {
val operand = translate(expression.operand)
"${expression.operator} $operand"
}
}
}
}
We'll combine this translator for the where
clause of a query with a translator for the other bits of a query, to produce a complete SQL expression:
fun interface QueryTranslator<T> {
fun translate(query: Query): T
}
object SimpleStringQueryTranslator : QueryTranslator<String> {
override fun translate(query: Query): String {
val select = query.select.joinToString(", ")
val where = query.where?.let { SimpleStringExpressionTranslator.translate(it) }
val groupBy = query.groupBy?.joinToString(", ")
val orderBy = query.orderBy?.joinToString(", ")
val limit = query.limit
val offset = query.offset
return """
SELECT $select
FROM ${query.from}
${where?.let { "WHERE $it" } ?: ""}
${groupBy?.let { "GROUP BY $it" } ?: ""}
${orderBy?.let { "ORDER BY $it" } ?: ""}
${limit?.let { "LIMIT $it" } ?: ""}
${offset?.let { "OFFSET $it" } ?: ""}
""".trimIndent()
}
}
Testing the String translator
Now we can test our simple translator in the Kotlin Playground:
val expression = AND(
OR(
EQ(
COLUMN("name"),
LITERAL("John")
),
LT(
COLUMN("age"),
LITERAL(30)
)
),
NOT(
EQ(
COLUMN("id"),
LITERAL(42)
)
)
)
val query = Query(
from = "users",
select = listOf("id", "name", "age"),
orderBy = listOf("name"),
limit = 5,
offset = 2,
where = expression
)
val t = SimpleStringQueryTranslator
val result = t.translate(query)
println(result)
The output should be:
SELECT id, name, age
FROM users
WHERE name = John OR age < 30 AND NOT id = 42
ORDER BY name
LIMIT 5
OFFSET 2
The jOOQ translator
Now we'll implement the above, but using jOOQ's DSL API.
object jOOQExpressionTranslator : ExpressionTranslator<QueryPart> {
override fun translate(expression: Expression): QueryPart {
return when (expression) {
is COLUMN -> field(name(expression.name))
is LITERAL -> inline(expression.value)
is BinaryOperation -> {
val left = translate(expression.left)
val right = translate(expression.right)
when (expression) {
is AND -> and(left as Condition, right as Condition)
is OR -> or(left as Condition, right as Condition)
is EQ -> (left as Field<Any>).eq(right)
is NEQ -> (left as Field<Any>).ne(right)
is LT -> (left as Field<Any>).lt(right)
is LTE -> (left as Field<Any>).le(right)
is GT -> (left as Field<Any>).gt(right)
is GTE -> (left as Field<Any>).ge(right)
is IN -> (left as Field<Any>).`in`(right as Any)
}
}
is UnaryOperation -> {
val operand = translate(expression.operand)
when (expression) {
is NOT -> not(operand as Condition)
is IS_NULL -> (operand as Field<Any>).isNull
}
}
}
}
}
object jOOQQueryTranslator : QueryTranslator<String> {
private val ctx: DSLContext = DSL.using(
SQLDialect.POSTGRES,
Settings()
.withRenderFormatted(true)
.withRenderQuotedNames(RenderQuotedNames.EXPLICIT_DEFAULT_UNQUOTED)
)
override fun translate(query: Query): String {
val statement = select(query.select.map { field(name(it)) })
.from(table(name(query.from)))
.apply {
query.where?.let { where(jOOQExpressionTranslator.translate(it) as Condition) }
query.groupBy?.let { groupBy(it.map { field(name(it)) }) }
query.orderBy?.let { orderBy(it.map { field(name(it)) }) }
query.limit?.let { limit(inline(it)) }
query.offset?.let { offset(inline(it)) }
}
return ctx.render(statement)
}
}
Testing the jOOQ translator
Unfortunately due to needing the jOOQ library, we can't test this in the Kotlin Playground.
But, I've uploaded the full working application to repl.it that you can access here:
Note: Scroll up slightly in the
Console
panel at the bottom of the repl.it embed below to see theprintln()
output of the generated SQL