2022-02-13
Kotlin has amazing capabilities for writing DSLs. A nice use case of this is in SQL or SQL-like where clauses. Consider the following example:
.filter { (name eq "test") and (id eq 1) } People
This reads nicely and many libraries support this style, for example, Exposed, Ktorm and kdbc.
When implementing this myself (not for SQL) I ran into a problem: How to limit the types used in the query. This was important because I only knew how to convert certain types into the query format (such as String, Int, LocalDate, but not Lists, ByteArray, etc.) 1.
Let’s first get an understanding of the basic setup. An entity is defined via its properties 2. Here this consists of a generic type and a name.
data class Property<T>(val name : String)
object People {
val name = Property<String>("name")
val id = Property<Int>("id")
}
A Filter
is made up of parts
that are joined together by operators. We want each part of a filter to
be typed and know how to convert itself to the output query. A filter
then is nothing special: Just a part that happens to have a type of
Boolean.
fun interface FilterPart<T> {
fun build() : String
}
typealias Filter = FilterPart<Boolean>
We are now ready for the first version of the filter function. It is quite trivial:
fun <E> E.filter(f : E.() -> Filter): Filter = f(this)
The interesting parts are the the eq
and and
function.
Before going into their implementation we note that both functions take two existing parts and combine them. So let’s create a quick helper class for this.
class BiOpFilterPart<T>(
val left : FilterPart<*>,
val op : String,
val right : FilterPart<*>
) : FilterPart<T> {
override fun build(): String =
"(${left.build()} $op ${right.build()})"
}
Since the types of the left and right parts are irrelevant, only the output type is used, we can just star them out.
A simple implementation of and
and eq
looks like this:
infix fun Filter.and(other : Filter) : Filter =
(left, "and", right)
BiOpFilterPart
infix fun <T> FilterPart<T>.eq(other : FilterPart<T>) : Filter =
(left, "eq", right) BiOpFilterPart
Does that cover the DSL? Well, no. We cannot simply write name eq "test"
since neither name nor “test” are FilterPart
s. For the name
property this is a simple fix, by letting Property<T>
implement FilterPart<T>
.
class Property<T>(
private val name: String
) : FilterPart<T> {
override fun build(): String = name
}
The same cannot be done for "test"
.
We cannot change the String class.
So let’s just let our eq
function take
T
:
infix fun <T> FilterPart<T>.eq(other : T) : Filter =
(left, "eq", wrap(right)) BiOpFilterPart
Then just define a function wrap
that switches on type T
to create a fitting FilterPart
. This is how Exposed
does it. However, this is where my problem comes in. Exposed can do
this because it knows the type T will be a type it can handle. I do
not.
My initial compromise was to create wrapper functions.
fun string(s : String) = FilterPart<String> { "'$s'" }
fun int(i : Int) = FilterPart<String> { i.toString() }
We then have to bite the bullet and write (id eq int(1)) and (name eq string("test"))
.
This still reads fine and uses the simple definitions in the first
draft.
What we really want is to tell the eq
function to take
in any T
, but only if there is also a way to express that
type in the query output. Enter the new
context receivers. We define an interface
Expressable
:
interface Expressable<T> {
fun T.asFilterPart(): FilterPart<T>
}
and require it for the eq
function. We can delegate back
to the function from the first draft:
(Expressable<T>)
contextinfix fun <T> FilterPart<T>.eq(other : T) : Filter =
this eq other.asFilterPart()
To inject this into the scope where needed we modify the
filter
function
object IntExpressable : Expressable<Int> { ... }
object StringExpressable : Expressable<String> { ... }
fun <E> E.filter(
f : context(Expressable<Int>, Expressable<String>) E.() -> Filter
: Filter =
)(IntExpressable, StringExpressable, this) f
And voilà; our goal has been achieved. We can use constants in the filter without wrapping them first and effectively control which types are allowed 3.
The previous solution has a drawback in that we need to overload the
eq
method to achieve both name eq "test"
and "test" eq name
.
Can we unify them into one mega-function? My idea here is to create a
new “type class” ExpressableAs<T, R>
which denotes that we know how to express something of type T into a
query of type R.
interface ExpressableAs<T, R> {
fun T.toFilterPart(): FilterPart<R>
}
The simple cases ExpressableAs<String, String>
and ExpressableAs<Int, Int>
can be derived from IntExpressable
and StringExpressable
respectively. The new
case of ExpressableAs<Property<T>, T>
is actually a special case of ExpressableAs<FilterPart<T>, T>
.
This is easily implemented by
fun <T> filterPartExpressableAs(): ExpressableAs<FilterPart<T>, T> =
object : ExpressableAs<FilterPart<T>, T> {
override fun FilterPart<T>.toFilterPart(): FilterPart<T> =
this
}
Armed with the new interface we define a single eq
function as follows (and also a new
and
, because why not)
(ExpressableAs<A, T>, ExpressableAs<B, T>)
context infix fun <A, B, T> A.eq(other : B) : Filter =
(this.toFilterPart(), "eq", other.toFilterPart())
BiOpFilter
(ExpressableAs<A, Boolean>, ExpressableAs<B, Boolean>)
context infix fun <A, B> A.and(other: B): Filter =
(this.toFilterPart(), "and", other.toFilterPart()) BiOpFilter
Sadly, there is one snag: Even though we can create ExpressableAs<FilterPart<T>, T>
for any type T, to actually use it for a specific type we need to bring
a specific instance into scope. The filter
function now looks like a mess4:
fun <E> E.filter(
f : context(ExpressableAs<Int, Int>,
ExpressableAs<FilterPart<Int>, Int>,
ExpressableAs<String, String>,
ExpressableAs<FilterPart<String>, String>,
ExpressableAs<FilterPart<Boolean>, Boolean>
) E.() -> Filter): Filter = TODO()
Maybe we should have stopped at the previous solution.
We have seen how context receivers can help keep our DSL clean by plumbing through needed information. I’m looking forward to see what others do with this new Kotlin feature.
If anyone comes up with a better solution please let me know. Maybe an alternative would be Arrow proofs? If it gets picked back up again.
And the types in the entities could contain such an unsupported type↩︎
In an actual system it would also contain further metadata such as the table name↩︎
If a client knows how to express some other types as well, it can easily add them to the context.↩︎
Can you spot why we need
ExpressableAs<FilterPart<Boolean>, Boolean>
?↩︎